mirror of
https://github.com/fastapiadmin/FastapiAdmin.git
synced 2026-10-16 22:51:14 +00:00
refactor: 小程序端代码瘦身与功能补齐
- 清理冗余代码与配置: - 删除废弃的 mock 适配器、通用 mock 模块与示例API - 移除冗余的 ts 类型文件与 patch 目录 - 简化 tsconfig 组件路径配置 - 新增功能与优化: - 新增全局水印组件与水印配置 - 接入微信小程序登录/手机号登录 - 新增系统参数配置 store 与国际化支持 - 新增 markdown 渲染插件与样式配置 - 优化 pinia 持久化逻辑修复 - 新增微信小程序 access_token 缓存配置 - 修复登录趋势数据结构定义 - 优化全局请求防抖重定向逻辑 - 页面与路由优化: - 补全引导页首次启动逻辑 - 统一页面底部安全区适配 - 更新页面路由类型声明 - 代码质量优化: - 移除无用 console.log 调试代码 - 修复全局弹窗重复重登录问题 - 优化 mp-html 组件配置
This commit is contained in:
@@ -1,24 +1,31 @@
|
||||
<script setup lang="ts">
|
||||
import type { SwiperItem } from '@wot-ui/ui/components/wd-swiper/types'
|
||||
import type { DashboardStats } from '@/api/module_monitor/dashboard'
|
||||
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 { BarChart, PieChart } from 'echarts/charts'
|
||||
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 { DashboardAPI } from '@/api/module_monitor/dashboard'
|
||||
import { NoticeAPI } from '@/api/module_system/notice'
|
||||
import { TicketAPI } from '@/api/module_system/ticket'
|
||||
import { useShare } from '@/composables/useShare'
|
||||
import { useUserStore } from '@/store/userStore'
|
||||
|
||||
useShare(() => ({
|
||||
title: `${getGreeting()}!${userStore.userInfo?.name || '管理员'} 邀你使用 FastapiAdmin`,
|
||||
path: '/pages/index/index',
|
||||
}))
|
||||
|
||||
echarts.use([
|
||||
GridComponent,
|
||||
LegendComponent,
|
||||
TooltipComponent,
|
||||
BarChart,
|
||||
PieChart,
|
||||
LegacyGridContainLabel,
|
||||
LineChart,
|
||||
CanvasRenderer,
|
||||
])
|
||||
|
||||
@@ -44,6 +51,10 @@ const dashboardStats = ref<DashboardStats | null>(null)
|
||||
const pendingTickets = ref(0)
|
||||
const recentNotices = ref<NoticeItem[]>([])
|
||||
const userStore = useUserStore()
|
||||
|
||||
/** 最新公告标题(通知栏展示,无公告则隐藏通知栏) */
|
||||
const latestNotice = computed(() => recentNotices.value[0]?.notice_title || '')
|
||||
|
||||
/** 页面滚动距离(供 wd-backtop 判断显示) */
|
||||
const scrollTop = ref(0)
|
||||
onPageScroll((e) => {
|
||||
@@ -51,8 +62,10 @@ onPageScroll((e) => {
|
||||
})
|
||||
|
||||
const NAV_LIST = [
|
||||
{ icon: 'user', title: '用户管理', name: 'work-users', color: '#4F8CFF' },
|
||||
{ icon: 'notification', title: '通知公告', name: 'work-notices', color: '#10B981' },
|
||||
{ icon: 'message', title: '工单管理', name: 'work-tickets', color: '#F59E0B' },
|
||||
{ icon: 'interaction', title: 'AI 助手', name: 'work-chat', color: '#06B6D4' },
|
||||
{ icon: 'robot', title: 'AI 模型', name: 'work-ai-models', color: '#8B5CF6' },
|
||||
]
|
||||
|
||||
/** 轮播 Banner 条目 */
|
||||
@@ -80,7 +93,7 @@ const banners = computed<BannerItem[]>(() => [
|
||||
subtitle: getDateString(),
|
||||
desc: '欢迎回来,开启高效的一天',
|
||||
cta: '数据概览',
|
||||
onClick: () => navigateTo('dashboard'),
|
||||
onClick: () => scrollToMonitor(),
|
||||
},
|
||||
{
|
||||
key: 'ticket',
|
||||
@@ -100,7 +113,7 @@ const banners = computed<BannerItem[]>(() => [
|
||||
subtitle: '系统运行状态与趋势分析',
|
||||
desc: `总用户 ${dashboardStats.value?.total_users ?? '-'} · 在线 ${dashboardStats.value?.online_users ?? '-'} · 今日登录 ${dashboardStats.value?.today_login_count ?? '-'}`,
|
||||
cta: '查看详情',
|
||||
onClick: () => navigateTo('dashboard'),
|
||||
onClick: () => scrollToMonitor(),
|
||||
},
|
||||
])
|
||||
|
||||
@@ -110,63 +123,112 @@ function navigateTo(name: string) {
|
||||
r.push({ name })
|
||||
}
|
||||
|
||||
/** 运营概览图表:在线占比环形图(真实数据,未加载时以 0 值占位) */
|
||||
const onlineDonutOption = computed(() => {
|
||||
const s = dashboardStats.value
|
||||
const online = s?.online_users ?? 0
|
||||
const total = s?.total_users ?? 0
|
||||
return {
|
||||
tooltip: {
|
||||
trigger: 'item',
|
||||
formatter: '{b}: {c} ({d}%)',
|
||||
textStyle: { fontSize: 12 },
|
||||
},
|
||||
legend: { show: false },
|
||||
series: [{
|
||||
type: 'pie',
|
||||
radius: ['58%', '82%'],
|
||||
center: ['50%', '50%'],
|
||||
avoidLabelOverlap: false,
|
||||
label: { show: false },
|
||||
emphasis: { scale: false },
|
||||
data: [
|
||||
{ value: online, name: '在线用户', itemStyle: { color: '#4F8CFF' } },
|
||||
{ value: Math.max(total - online, 0), name: '离线用户', itemStyle: { color: 'rgba(79, 140, 255, 0.14)' } },
|
||||
],
|
||||
}],
|
||||
}
|
||||
})
|
||||
// ===== 系统监控(原数据概览 dashboard 合并) =====
|
||||
|
||||
/** 运营概览图表:今日数据柱状图(真实数据,未加载时以 0 值占位) */
|
||||
const todayBarOption = computed(() => {
|
||||
const s = dashboardStats.value
|
||||
/** 近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',
|
||||
axisPointer: { type: 'shadow' },
|
||||
textStyle: { fontSize: 12 },
|
||||
},
|
||||
grid: { left: 4, right: 4, top: 18, bottom: 0, containLabel: true },
|
||||
legend: {
|
||||
top: 0,
|
||||
right: 0,
|
||||
itemWidth: 10,
|
||||
itemHeight: 10,
|
||||
itemGap: 8,
|
||||
textStyle: { fontSize: 10, color: '#909399' },
|
||||
data: ['登录次数', '独立访客', '新增用户'],
|
||||
},
|
||||
grid: { left: 8, right: 8, top: 34, bottom: 0, containLabel: true },
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: ['今日登录', '本周新增', '独立用户'],
|
||||
boundaryGap: false,
|
||||
data: days,
|
||||
axisLine: { show: false },
|
||||
axisTick: { show: false },
|
||||
axisLabel: { fontSize: 10, color: '#999' },
|
||||
},
|
||||
yAxis: { type: 'value', show: false },
|
||||
series: [{
|
||||
type: 'bar',
|
||||
barWidth: 14,
|
||||
data: [
|
||||
{ value: s?.today_login_count ?? 0, itemStyle: { color: '#4F8CFF', borderRadius: [6, 6, 0, 0] } },
|
||||
{ value: s?.week_user_created ?? 0, itemStyle: { color: '#10B981', borderRadius: [6, 6, 0, 0] } },
|
||||
{ value: s?.today_unique_users ?? 0, itemStyle: { color: '#F59E0B', borderRadius: [6, 6, 0, 0] } },
|
||||
],
|
||||
}],
|
||||
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: '登录次数',
|
||||
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: '独立访客',
|
||||
type: 'line',
|
||||
smooth: true,
|
||||
symbol: 'circle',
|
||||
symbolSize: 5,
|
||||
data: uniques,
|
||||
lineStyle: { width: 2.5, color: '#10B981' },
|
||||
itemStyle: { color: '#10B981' },
|
||||
},
|
||||
{
|
||||
name: '新增用户',
|
||||
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 {
|
||||
@@ -174,8 +236,10 @@ async function loadData() {
|
||||
DashboardAPI.getStats(),
|
||||
TicketAPI.getPage({ page_no: 1, page_size: 1, status: '0' }),
|
||||
])
|
||||
if (statsRes.status === 'fulfilled')
|
||||
if (statsRes.status === 'fulfilled') {
|
||||
dashboardStats.value = statsRes.value
|
||||
loginTrend.value = statsRes.value.login_trend || []
|
||||
}
|
||||
if (ticketRes.status === 'fulfilled')
|
||||
pendingTickets.value = ticketRes.value.total || 0
|
||||
}
|
||||
@@ -231,22 +295,24 @@ function getGreeting() {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view class="box-border min-h-screen">
|
||||
<!-- 顶部通知栏(info 类型跟随主题色:背景 --wot-primary-1 / 文字 --wot-primary-6;暗黑模式下经 home-notice 覆盖为半透明主色底) -->
|
||||
<view class="page-wraper">
|
||||
<!-- 顶部通知栏:接入最新一条公告(无公告自动隐藏),点击进入公告列表 -->
|
||||
<wd-notice-bar
|
||||
text="这是一条消息提示信息,这是一条消息提示信息,这是一条消息提示信息"
|
||||
v-if="latestNotice"
|
||||
: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="100"
|
||||
height="120"
|
||||
radius="14"
|
||||
:interval="4500"
|
||||
:autoplay="true"
|
||||
@@ -371,19 +437,23 @@ function getGreeting() {
|
||||
</wd-row>
|
||||
</view>
|
||||
|
||||
<!-- 运营图表(未加载数据时以 0 值占位渲染) -->
|
||||
<view class="grid grid-cols-2 mx-3 mt-3 gap-3">
|
||||
<view class="rounded-2 p-3 wot-bg-filled-oppo">
|
||||
<view class="mb-1 text-center text-3 font-bold wot-text-text-main">
|
||||
在线占比
|
||||
</view>
|
||||
<uni-echarts custom-class="h-44 w-full" :option="onlineDonutOption" />
|
||||
<!-- 系统监控(原数据概览 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">
|
||||
系统监控
|
||||
</text>
|
||||
</view>
|
||||
<view class="rounded-2 p-3 wot-bg-filled-oppo">
|
||||
<view class="mb-1 text-center text-3 font-bold wot-text-text-main">
|
||||
今日数据
|
||||
|
||||
<!-- 登录趋势折线图 -->
|
||||
<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">
|
||||
登录趋势 (近7天)
|
||||
</text>
|
||||
</view>
|
||||
<uni-echarts custom-class="h-44 w-full" :option="todayBarOption" />
|
||||
<uni-echarts custom-class="h-52 w-full" :option="loginTrendOption" />
|
||||
</view>
|
||||
</view>
|
||||
|
||||
@@ -417,50 +487,6 @@ function getGreeting() {
|
||||
</wd-cell-group>
|
||||
</view>
|
||||
|
||||
<!-- 最近登录 -->
|
||||
<view v-if="dashboardStats?.recent_logins?.length" 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(--warning-color, #F59E0B);" />
|
||||
<text class="text-3.5 font-bold wot-text-text-main">
|
||||
最近登录
|
||||
</text>
|
||||
</view>
|
||||
<text class="text-3 wot-text-primary" @click="navigateTo('work-loginlogs')">
|
||||
全部
|
||||
</text>
|
||||
</view>
|
||||
<view v-if="dashboardStats?.recent_logins?.length" class="mx-3">
|
||||
<wd-cell-group border custom-class="rounded-2! overflow-hidden">
|
||||
<wd-cell
|
||||
v-for="(item, i) in dashboardStats.recent_logins.slice(0, 5)"
|
||||
:key="i"
|
||||
:title="item.username"
|
||||
>
|
||||
<template #label>
|
||||
<text class="text-2.5 wot-text-text-auxiliary">
|
||||
{{ item.login_time || '' }}{{ item.login_ip ? ` · ${item.login_ip}` : '' }}
|
||||
</text>
|
||||
</template>
|
||||
<template #value>
|
||||
<StatusBadge :status="item.status === 1 ? 'success' : 'failed'" />
|
||||
</template>
|
||||
</wd-cell>
|
||||
</wd-cell-group>
|
||||
<wd-loadmore state="finished">
|
||||
<template #finished>
|
||||
<view
|
||||
class="flex items-center justify-center py-1"
|
||||
@click="navigateTo('work-loginlogs')"
|
||||
>
|
||||
<text class="text-3 wot-text-primary">
|
||||
加载更多
|
||||
</text>
|
||||
<wd-icon name="arrow-right" size="14px" custom-class="wot-text-primary" />
|
||||
</view>
|
||||
</template>
|
||||
</wd-loadmore>
|
||||
</view>
|
||||
|
||||
<!-- Bottom safe area -->
|
||||
<wd-gap height="100rpx" safe-area-bottom />
|
||||
<wd-backtop :scroll-top="scrollTop" :top="80" />
|
||||
@@ -480,7 +506,7 @@ function getGreeting() {
|
||||
color: #FFFFFF;
|
||||
overflow: hidden;
|
||||
|
||||
/* 装饰圆环 */
|
||||
/* 装饰圆环(极光浮动动效) */
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
@@ -490,6 +516,7 @@ function getGreeting() {
|
||||
height: 300rpx;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 0.10);
|
||||
animation: banner-orbit 7s ease-in-out infinite;
|
||||
}
|
||||
|
||||
&::after {
|
||||
@@ -501,6 +528,13 @@ function getGreeting() {
|
||||
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); }
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
<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 { REMEMBER_ME_KEY } from '@/constants'
|
||||
import { Storage } from '@/utils/storage'
|
||||
|
||||
definePage({ name: 'forget', style: { navigationBarTitleText: '忘记密码' } })
|
||||
|
||||
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('forget.usernameRequired') })
|
||||
else if (!USERNAME_REG.test(username))
|
||||
errors.push({ path: ['username'], message: t('forget.usernameFormat') })
|
||||
if (!password)
|
||||
errors.push({ path: ['new_password'], message: t('forget.passwordRequired') })
|
||||
else if (password.length < 6)
|
||||
errors.push({ path: ['new_password'], message: t('forget.passwordLength') })
|
||||
if (!confirmPassword)
|
||||
errors.push({ path: ['confirmPassword'], message: t('forget.confirmRequired') })
|
||||
else if (confirmPassword !== password)
|
||||
errors.push({ path: ['confirmPassword'], message: t('forget.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('forget.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('forget.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('forget.confirmPlaceholder')"
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/* 暗黑下 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-sm, 24rpx);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -3,20 +3,31 @@ 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 { nextTick, reactive, ref, watch } from 'vue'
|
||||
import { computed, nextTick, reactive, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import AuthAPI from '@/api/module_system/auth'
|
||||
import { REMEMBER_ME_KEY } from '@/constants'
|
||||
import { useWxLogin } from '@/composables/useWxLogin'
|
||||
import { useConfigStore } from '@/store/configStore'
|
||||
import { useUserStore } from '@/store/userStore'
|
||||
import { Storage } from '@/utils/storage'
|
||||
|
||||
definePage({ name: 'login', style: { navigationBarTitleText: '登录' } })
|
||||
|
||||
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')
|
||||
|
||||
/** 品牌区参数(来自后端系统参数,带默认值兜底;web 端消费方式:configData?.[key]?.config_value) */
|
||||
const brandLogo = computed(() => configStore.configData?.logo_url?.config_value?.trim() || '/static/logo.png')
|
||||
const brandTitle = computed(() => configStore.configData?.sys_name?.config_value?.trim() || 'FastapiAdmin')
|
||||
const brandSubtitle = computed(() => configStore.configData?.login_title?.config_value?.trim() || '高效的企业管理移动平台')
|
||||
|
||||
/** 表单验证 schema — 用户名/密码字段级错误提示(wot-ui 函数式校验) */
|
||||
const loginSchema: FormSchema = {
|
||||
validate: (model) => {
|
||||
@@ -24,13 +35,13 @@ const loginSchema: FormSchema = {
|
||||
const username = String(model.username ?? '')
|
||||
const password = String(model.password ?? '')
|
||||
if (!username)
|
||||
errors.push({ path: ['username'], message: '请输入用户名' })
|
||||
errors.push({ path: ['username'], message: t('login.usernameRequired') })
|
||||
else if (username.length < 3 || username.length > 20)
|
||||
errors.push({ path: ['username'], message: '用户名长度为 3-20 个字符' })
|
||||
errors.push({ path: ['username'], message: t('login.usernameLength') })
|
||||
if (!password)
|
||||
errors.push({ path: ['password'], message: '请输入密码' })
|
||||
errors.push({ path: ['password'], message: t('login.passwordRequired') })
|
||||
else if (password.length < 6 || password.length > 20)
|
||||
errors.push({ path: ['password'], message: '密码长度为 6-20 个字符' })
|
||||
errors.push({ path: ['password'], message: t('login.passwordLength') })
|
||||
return errors
|
||||
},
|
||||
}
|
||||
@@ -109,7 +120,7 @@ async function getLoginCaptcha() {
|
||||
*/
|
||||
async function handleSliderSuccess() {
|
||||
if (!captchaState.key) {
|
||||
uni.showToast({ title: '验证码已过期,请刷新', icon: 'none' })
|
||||
uni.showToast({ title: t('login.captchaExpired'), icon: 'none' })
|
||||
sliderCaptchaRef.value?.reset()
|
||||
return
|
||||
}
|
||||
@@ -124,13 +135,13 @@ async function handleSliderSuccess() {
|
||||
loginFormData.captcha = 'verified' // 占位值,后端只校验 captcha_key 状态
|
||||
}
|
||||
else {
|
||||
uni.showToast({ title: '验证失败,请重试', icon: 'none' })
|
||||
uni.showToast({ title: t('login.sliderFailed'), icon: 'none' })
|
||||
sliderCaptchaRef.value?.reset()
|
||||
}
|
||||
}
|
||||
catch (e) {
|
||||
console.error('滑块验证失败', e)
|
||||
uni.showToast({ title: '验证失败,请重试', icon: 'none' })
|
||||
uni.showToast({ title: t('login.sliderFailed'), icon: 'none' })
|
||||
sliderCaptchaRef.value?.reset()
|
||||
}
|
||||
}
|
||||
@@ -150,6 +161,8 @@ onLoad((options) => {
|
||||
}
|
||||
restoreRememberedUser()
|
||||
getLoginCaptcha()
|
||||
// 拉取系统参数(品牌区 logo/标题/副标题),幂等 + 本地持久化缓存
|
||||
configStore.getConfig()
|
||||
})
|
||||
|
||||
/** 登录提交 — 防抖 + 字段验证 + 滑块校验 + 错误提示 + 记住密码 */
|
||||
@@ -159,7 +172,7 @@ async function handleSubmit() {
|
||||
|
||||
// 滑块验证前置校验
|
||||
if (captchaState.enable && !sliderPassed.value) {
|
||||
uni.showToast({ title: '请先完成滑块验证', icon: 'none' })
|
||||
uni.showToast({ title: t('login.sliderRequired'), icon: 'none' })
|
||||
return
|
||||
}
|
||||
|
||||
@@ -181,7 +194,7 @@ async function handleSubmit() {
|
||||
uni.reLaunch({ url: redirect.value })
|
||||
}
|
||||
catch {
|
||||
uni.showToast({ title: '登录失败,请检查账号密码', icon: 'none', duration: 2500 })
|
||||
uni.showToast({ title: t('login.loginFailed'), icon: 'none', duration: 2500 })
|
||||
// 登录失败后自动刷新验证码并重置滑块
|
||||
if (captchaState.enable)
|
||||
getLoginCaptcha()
|
||||
@@ -191,6 +204,16 @@ async function handleSubmit() {
|
||||
}
|
||||
}
|
||||
|
||||
/** 跳转注册页 */
|
||||
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 {
|
||||
@@ -203,14 +226,51 @@ async function handleOAuth(provider: OAuthProvider) {
|
||||
uni.setClipboardData({
|
||||
data: result.url,
|
||||
success: () => {
|
||||
uni.showToast({ title: '链接已复制,请在浏览器中打开', icon: 'none' })
|
||||
uni.showToast({ title: t('login.oauthCopyTip'), icon: 'none' })
|
||||
},
|
||||
})
|
||||
// #endif
|
||||
}
|
||||
}
|
||||
catch {
|
||||
uni.showToast({ title: 'OAuth登录失败', icon: 'none' })
|
||||
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: '微信登录失败,请重试', icon: 'none' })
|
||||
}
|
||||
finally {
|
||||
wxLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 微信手机号登录(配合 open-type="getPhoneNumber") */
|
||||
async function handleWxPhoneLogin(e: any) {
|
||||
if (e.detail.errMsg !== 'getPhoneNumber:ok') {
|
||||
uni.showToast({ title: '已取消手机号授权', icon: 'none' })
|
||||
return
|
||||
}
|
||||
wxLoading.value = true
|
||||
try {
|
||||
await wxPhoneLogin(e.detail.code)
|
||||
uni.reLaunch({ url: redirect.value })
|
||||
}
|
||||
catch {
|
||||
uni.showToast({ title: '手机号登录失败,请重试', icon: 'none' })
|
||||
}
|
||||
finally {
|
||||
wxLoading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -219,27 +279,55 @@ async function handleOAuth(provider: OAuthProvider) {
|
||||
<view class="login-page">
|
||||
<!-- Brand area -->
|
||||
<view class="login-brand">
|
||||
<image class="brand-logo" src="/static/logo.png" mode="aspectFit" />
|
||||
<image class="brand-logo" :src="brandLogo" mode="aspectFit" />
|
||||
<text class="brand-title">
|
||||
FastapiAdmin
|
||||
{{ 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">微信一键登录</text>
|
||||
</wd-button>
|
||||
<wd-button
|
||||
plain
|
||||
round
|
||||
block
|
||||
custom-class="mt-3"
|
||||
:loading="wxLoading"
|
||||
open-type="getPhoneNumber"
|
||||
@getphonenumber="handleWxPhoneLogin"
|
||||
>
|
||||
手机号快速登录
|
||||
</wd-button>
|
||||
</view>
|
||||
<wd-divider>或账号密码登录</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="请输入用户名"
|
||||
:placeholder="$t('login.usernamePlaceholder')"
|
||||
clearable
|
||||
confirm-type="next"
|
||||
:compact="false"
|
||||
@@ -252,9 +340,9 @@ async function handleOAuth(provider: OAuthProvider) {
|
||||
<wd-form-item prop="password">
|
||||
<wd-input
|
||||
v-model="loginFormData.password"
|
||||
placeholder="请输入密码"
|
||||
show-password
|
||||
:placeholder="$t('login.passwordPlaceholder')"
|
||||
clearable
|
||||
show-password
|
||||
confirm-type="go"
|
||||
:compact="false"
|
||||
prefix-icon="lock"
|
||||
@@ -266,8 +354,8 @@ async function handleOAuth(provider: OAuthProvider) {
|
||||
<wd-form-item v-if="captchaState.enable">
|
||||
<wd-slide-verify
|
||||
ref="sliderCaptchaRef"
|
||||
text="向右滑动滑块验证"
|
||||
success-text="验证成功"
|
||||
:text="$t('login.sliderText')"
|
||||
:success-text="$t('login.sliderSuccessText')"
|
||||
@success="handleSliderSuccess"
|
||||
@fail="resetSliderCaptcha"
|
||||
/>
|
||||
@@ -276,10 +364,10 @@ async function handleOAuth(provider: OAuthProvider) {
|
||||
<!-- 记住密码 + 忘记密码 -->
|
||||
<view class="login-options">
|
||||
<wd-checkbox v-model="loginFormData.remember" size="18px">
|
||||
记住密码
|
||||
{{ $t('login.remember') }}
|
||||
</wd-checkbox>
|
||||
<text class="forgot-link wot-text-primary">
|
||||
忘记密码?
|
||||
<text class="forgot-link wot-text-primary" @click="goForget">
|
||||
{{ $t('login.forgot') }}
|
||||
</text>
|
||||
</view>
|
||||
|
||||
@@ -291,67 +379,64 @@ async function handleOAuth(provider: OAuthProvider) {
|
||||
block
|
||||
@click="handleSubmit"
|
||||
>
|
||||
{{ loading ? '登录中...' : '登 录' }}
|
||||
{{ loading ? $t('login.submitting') : $t('login.submit') }}
|
||||
</wd-button>
|
||||
</wd-form>
|
||||
</view>
|
||||
|
||||
<!-- OAuth Login -->
|
||||
<view class="oauth-section">
|
||||
<view class="oauth-divider">
|
||||
<view class="oauth-divider__line" />
|
||||
<text class="oauth-divider__text">
|
||||
第三方登录
|
||||
</text>
|
||||
<view class="oauth-divider__line" />
|
||||
</view>
|
||||
<view class="oauth-buttons">
|
||||
<wd-divider>{{ $t('login.thirdParty') }}</wd-divider>
|
||||
<wd-grid :column="4" clickable :gutter="10">
|
||||
<!-- WeChat -->
|
||||
<view class="oauth-btn" @click="handleOAuth('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>
|
||||
</view>
|
||||
</wd-grid-item>
|
||||
|
||||
<!-- GitHub -->
|
||||
<view class="oauth-btn" @click="handleOAuth('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">
|
||||
GitHub
|
||||
{{ $t('login.github') }}
|
||||
</text>
|
||||
</view>
|
||||
</wd-grid-item>
|
||||
|
||||
<!-- Gitee -->
|
||||
<view class="oauth-btn" @click="handleOAuth('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">
|
||||
Gitee
|
||||
{{ $t('login.gitee') }}
|
||||
</text>
|
||||
</view>
|
||||
</wd-grid-item>
|
||||
|
||||
<!-- QQ -->
|
||||
<view class="oauth-btn" @click="handleOAuth('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">
|
||||
QQ
|
||||
{{ $t('login.qq') }}
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
</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>
|
||||
@@ -370,7 +455,7 @@ async function handleOAuth(provider: OAuthProvider) {
|
||||
height: 100vh;
|
||||
/* #endif */
|
||||
padding: 0 64rpx;
|
||||
padding-bottom: calc(48rpx + env(safe-area-inset-bottom));
|
||||
padding-bottom: calc(32rpx + env(safe-area-inset-bottom));
|
||||
background: var(--page-bg-color, #F9F9F9);
|
||||
overflow: hidden;
|
||||
box-sizing: border-box;
|
||||
@@ -386,15 +471,14 @@ async function handleOAuth(provider: OAuthProvider) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding-top: 72rpx;
|
||||
padding-bottom: 40rpx;
|
||||
gap: 12rpx;
|
||||
padding-top: 48rpx;
|
||||
padding-bottom: 32rpx;
|
||||
gap: 10rpx;
|
||||
flex-shrink: 0;
|
||||
|
||||
.brand-logo {
|
||||
width: 140rpx;
|
||||
height: 140rpx;
|
||||
margin-bottom: 4rpx;
|
||||
width: 104rpx;
|
||||
height: 104rpx;
|
||||
}
|
||||
|
||||
.brand-title {
|
||||
@@ -424,10 +508,10 @@ async function handleOAuth(provider: OAuthProvider) {
|
||||
width: 100%;
|
||||
background: var(--card-bg-color, #FFFFFF);
|
||||
border-radius: var(--radius-xl, 32rpx);
|
||||
padding: 40rpx 36rpx;
|
||||
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: 20rpx;
|
||||
margin-bottom: 12rpx;
|
||||
flex-shrink: 0;
|
||||
|
||||
/* 暗黑模式:卡片用深色 2 级,页面背景用深色 1 级,形成细微层级差 */
|
||||
@@ -442,7 +526,7 @@ async function handleOAuth(provider: OAuthProvider) {
|
||||
font-size: var(--font-xl, 36rpx);
|
||||
font-weight: 600;
|
||||
color: var(--text-color, #0A1628);
|
||||
margin-bottom: 28rpx;
|
||||
margin-bottom: 18rpx;
|
||||
|
||||
/* 暗黑模式下使用纯白,提升卡片标题醒目度 */
|
||||
.wot-theme-dark & {
|
||||
@@ -467,7 +551,10 @@ async function handleOAuth(provider: OAuthProvider) {
|
||||
}
|
||||
|
||||
:deep(.wd-form-item) {
|
||||
margin-bottom: 20rpx;
|
||||
margin-bottom: 14rpx;
|
||||
/* 去掉 wd-cell 自带左右内边距,使输入框/滑块与登录按钮同宽 */
|
||||
padding-left: 0;
|
||||
padding-right: 0;
|
||||
}
|
||||
|
||||
/* ===== 记住密码 + 忘记密码 ===== */
|
||||
@@ -475,8 +562,7 @@ async function handleOAuth(provider: OAuthProvider) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 28rpx;
|
||||
margin-top: 4rpx;
|
||||
margin-bottom: 16rpx;
|
||||
}
|
||||
|
||||
.forgot-link {
|
||||
@@ -486,77 +572,61 @@ async function handleOAuth(provider: OAuthProvider) {
|
||||
/* ===== OAuth Section ===== */
|
||||
.oauth-section {
|
||||
width: 100%;
|
||||
margin-top: 16rpx;
|
||||
margin-top: 8rpx;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.oauth-divider {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 24rpx;
|
||||
margin-bottom: 24rpx;
|
||||
|
||||
&__line {
|
||||
flex: 1;
|
||||
height: 1rpx;
|
||||
background: var(--border-color, #EAEAEA);
|
||||
}
|
||||
|
||||
&__text {
|
||||
font-size: var(--font-sm, 24rpx);
|
||||
color: var(--text-color-4, #B0B0B0);
|
||||
flex-shrink: 0;
|
||||
/* 去掉宫格项默认填充背景,仅保留图标/文字与轻量点击反馈 */
|
||||
:deep(.wd-grid-item) {
|
||||
--wot-grid-item-bg: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
.oauth-buttons {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 48rpx;
|
||||
}
|
||||
/* ===== 微信一键登录区域 ===== */
|
||||
.wx-login-section {
|
||||
margin-bottom: 16rpx;
|
||||
|
||||
.oauth-btn {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12rpx;
|
||||
|
||||
&__icon {
|
||||
width: 88rpx;
|
||||
:deep(.wd-button) {
|
||||
height: 88rpx;
|
||||
border-radius: 44rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: transform var(--transition-fast, 0.15s ease);
|
||||
font-size: 30rpx;
|
||||
}
|
||||
}
|
||||
|
||||
&:active &__icon {
|
||||
transform: scale(0.92);
|
||||
}
|
||||
.oauth-btn__icon {
|
||||
width: 72rpx;
|
||||
height: 72rpx;
|
||||
border-radius: 36rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
&__iconify {
|
||||
width: 48rpx;
|
||||
height: 48rpx;
|
||||
}
|
||||
.oauth-btn__iconify {
|
||||
width: 40rpx;
|
||||
height: 40rpx;
|
||||
}
|
||||
|
||||
&__label {
|
||||
font-size: var(--font-xs, 20rpx);
|
||||
color: var(--text-color-3, #6B7280);
|
||||
}
|
||||
.oauth-btn__label {
|
||||
font-size: var(--font-xs, 20rpx);
|
||||
color: var(--text-color-3, #6B7280);
|
||||
}
|
||||
|
||||
/* ===== Footer ===== */
|
||||
.login-footer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
padding: 24rpx 0 0;
|
||||
justify-content: center;
|
||||
gap: 8rpx;
|
||||
padding: 12rpx 0 0;
|
||||
flex-shrink: 0;
|
||||
|
||||
&__text {
|
||||
font-size: var(--font-sm, 24rpx);
|
||||
color: var(--text-color-4, #B0B0B0);
|
||||
color: var(--text-color-3, #6B7280);
|
||||
}
|
||||
|
||||
&__link {
|
||||
font-size: var(--font-sm, 24rpx);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,316 @@
|
||||
<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 { 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: '注册' } })
|
||||
|
||||
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('register.usernameRequired') })
|
||||
else if (!USERNAME_REG.test(username))
|
||||
errors.push({ path: ['username'], message: t('register.usernameFormat') })
|
||||
if (!name)
|
||||
errors.push({ path: ['name'], message: t('register.nicknameRequired') })
|
||||
if (!password)
|
||||
errors.push({ path: ['password'], message: t('register.passwordRequired') })
|
||||
else if (password.length < 6)
|
||||
errors.push({ path: ['password'], message: t('register.passwordLength') })
|
||||
if (!confirmPassword)
|
||||
errors.push({ path: ['confirmPassword'], message: t('register.confirmRequired') })
|
||||
else if (confirmPassword !== password)
|
||||
errors.push({ path: ['confirmPassword'], message: t('register.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('register.usernamePlaceholder')"
|
||||
clearable
|
||||
:compact="false"
|
||||
prefix-icon="user"
|
||||
/>
|
||||
</wd-form-item>
|
||||
<wd-form-item prop="name">
|
||||
<wd-input
|
||||
v-model="registerForm.name"
|
||||
:placeholder="$t('register.nicknamePlaceholder')"
|
||||
clearable
|
||||
:compact="false"
|
||||
prefix-icon="user"
|
||||
/>
|
||||
</wd-form-item>
|
||||
<wd-form-item prop="password">
|
||||
<wd-input
|
||||
v-model="registerForm.password"
|
||||
:placeholder="$t('register.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('register.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;
|
||||
}
|
||||
}
|
||||
|
||||
/* 暗黑下 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-sm, 24rpx);
|
||||
color: var(--text-color-3, #6B7280);
|
||||
}
|
||||
|
||||
&__link {
|
||||
font-size: var(--font-sm, 24rpx);
|
||||
}
|
||||
}
|
||||
|
||||
.register-footer {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 8rpx;
|
||||
margin-top: 32rpx;
|
||||
|
||||
&__text {
|
||||
font-size: var(--font-sm, 24rpx);
|
||||
color: var(--text-color-3, #6B7280);
|
||||
}
|
||||
|
||||
&__link {
|
||||
font-size: var(--font-sm, 24rpx);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,8 +1,16 @@
|
||||
<script lang="ts" setup>
|
||||
import { TicketAPI } from '@/api/module_system/ticket'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useGlobalDialog } from '@/composables/useGlobalDialog'
|
||||
import { getTicketStats } from '@/composables/useCachedRequest'
|
||||
import { useShare } from '@/composables/useShare'
|
||||
import { useSharePoster } from '@/composables/useSharePoster'
|
||||
import { useUserStore } from '@/store/userStore'
|
||||
|
||||
useShare(() => ({
|
||||
title: `${userInfo.value?.name || '管理员'} 邀请你加入 FastapiAdmin`,
|
||||
path: '/pages/index/index',
|
||||
}))
|
||||
|
||||
definePage({
|
||||
name: 'mine',
|
||||
layout: 'tabbar',
|
||||
@@ -10,6 +18,7 @@ definePage({
|
||||
})
|
||||
|
||||
const router = useRouter()
|
||||
const { t } = useI18n()
|
||||
const userStore = useUserStore()
|
||||
const userInfo = computed(() => userStore.userInfo)
|
||||
|
||||
@@ -21,9 +30,9 @@ const globalDialog = useGlobalDialog()
|
||||
|
||||
function handleLogout() {
|
||||
globalDialog.confirm({
|
||||
title: '提示',
|
||||
msg: '确认退出登录吗?',
|
||||
confirmButtonText: '退出',
|
||||
title: t('common.title'),
|
||||
msg: t('mine.logoutMsg'),
|
||||
confirmButtonText: t('mine.logoutConfirm'),
|
||||
success: (res) => {
|
||||
if (res.action === 'confirm')
|
||||
userStore.logout()
|
||||
@@ -31,26 +40,19 @@ function handleLogout() {
|
||||
})
|
||||
}
|
||||
|
||||
/** 通知开关(演示 wd-switch 使用) */
|
||||
const notifyEnabled = ref(false)
|
||||
|
||||
/** 工单统计(真实数据:待处理 / 处理中 / 已完成) */
|
||||
/** 工单统计(共享缓存,与 work 页面复用,30秒内不重复请求) */
|
||||
const pendingTickets = ref<number | null>(null)
|
||||
const processingTickets = ref<number | null>(null)
|
||||
const doneTickets = ref<number | null>(null)
|
||||
|
||||
async function loadTicketStats() {
|
||||
const [pending, processing, done] = await Promise.allSettled([
|
||||
TicketAPI.getPage({ page_no: 1, page_size: 1, status: '0' }),
|
||||
TicketAPI.getPage({ page_no: 1, page_size: 1, status: '1' }),
|
||||
TicketAPI.getPage({ page_no: 1, page_size: 1, status: '2' }),
|
||||
])
|
||||
if (pending.status === 'fulfilled')
|
||||
pendingTickets.value = pending.value.total || 0
|
||||
if (processing.status === 'fulfilled')
|
||||
processingTickets.value = processing.value.total || 0
|
||||
if (done.status === 'fulfilled')
|
||||
doneTickets.value = done.value.total || 0
|
||||
try {
|
||||
const stats = await getTicketStats()
|
||||
pendingTickets.value = stats.pending
|
||||
processingTickets.value = stats.processing
|
||||
doneTickets.value = stats.done
|
||||
}
|
||||
catch { /* silent */ }
|
||||
}
|
||||
|
||||
onShow(() => {
|
||||
@@ -62,21 +64,76 @@ onShow(() => {
|
||||
})
|
||||
|
||||
const settingsList = [
|
||||
{ title: '个人资料', name: 'profile' },
|
||||
{ title: '修改密码', name: 'account' },
|
||||
{ title: '关于我们', name: 'about' },
|
||||
{ titleKey: 'mine.profile', name: 'profile' },
|
||||
{ titleKey: 'mine.changePassword', name: 'account' },
|
||||
{ titleKey: 'mine.aboutUs', name: 'about' },
|
||||
]
|
||||
|
||||
const quickLinks = [
|
||||
{ title: '用户管理', name: 'work-users', icon: 'user', color: '#4F8CFF' },
|
||||
{ title: '通知公告', name: 'work-notices', icon: 'notification', color: '#10B981' },
|
||||
{ title: '工单管理', name: 'work-tickets', icon: 'message', color: '#F59E0B' },
|
||||
{ title: 'AI 助手', name: 'work-chat', icon: 'interaction', color: '#06B6D4' },
|
||||
{ title: 'AI 模型', name: 'work-ai-models', icon: 'robot', color: '#8B5CF6' },
|
||||
]
|
||||
|
||||
/** 邀请海报(获客):生成小程序码 → 预览 → 保存相册 */
|
||||
const { generateQrCode, saveImageToAlbum } = useSharePoster()
|
||||
const showPoster = ref(false)
|
||||
const posterUrl = ref('')
|
||||
const posterLoading = ref(false)
|
||||
|
||||
async function handleGeneratePoster() {
|
||||
// #ifdef MP-WEIXIN
|
||||
if (posterLoading.value)
|
||||
return
|
||||
posterLoading.value = true
|
||||
try {
|
||||
// 带参小程序码:scene 携带用户 ID(invite_xxx),扫码进入首页
|
||||
const scene = `invite_${userInfo.value?.id || 0}`
|
||||
const qr = await generateQrCode({ scene, page: 'pages/index/index' })
|
||||
if (!qr) {
|
||||
uni.showToast({ title: '生成失败,请稍后重试', icon: 'none' })
|
||||
return
|
||||
}
|
||||
posterUrl.value = qr
|
||||
showPoster.value = true
|
||||
}
|
||||
finally {
|
||||
posterLoading.value = false
|
||||
}
|
||||
// #endif
|
||||
|
||||
// #ifndef MP-WEIXIN
|
||||
uni.showToast({ title: '请在微信小程序中使用', icon: 'none' })
|
||||
// #endif
|
||||
}
|
||||
|
||||
/** 保存海报到相册(base64 → 临时文件 → 相册) */
|
||||
async function handleSavePoster() {
|
||||
// #ifdef MP-WEIXIN
|
||||
if (!posterUrl.value)
|
||||
return
|
||||
try {
|
||||
uni.showLoading({ title: '保存中...' })
|
||||
// base64 data URI → 写入本地临时文件
|
||||
const base64 = posterUrl.value.replace(/^data:image\/\w+;base64,/, '')
|
||||
const fs = uni.getFileSystemManager()
|
||||
const filePath = `${wx.env.USER_DATA_PATH}/invite_qr_${Date.now()}.png`
|
||||
fs.writeFileSync(filePath, base64, 'base64')
|
||||
await saveImageToAlbum(filePath)
|
||||
}
|
||||
catch {
|
||||
uni.showToast({ title: '保存失败', icon: 'none' })
|
||||
}
|
||||
finally {
|
||||
uni.hideLoading()
|
||||
}
|
||||
// #endif
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view class="box-border min-h-screen py-3">
|
||||
<view class="page-wraper py-3">
|
||||
<!-- 用户信息卡 -->
|
||||
<view class="mx-3 mb-3 flex items-center gap-4 rounded-3 px-5 py-6 wot-bg-filled-oppo">
|
||||
<wd-badge is-dot>
|
||||
@@ -99,13 +156,41 @@ const quickLinks = [
|
||||
<!-- 设置入口(纯 icon,打开设置页:内含主题设置) -->
|
||||
<view
|
||||
class="h-9 w-9 flex shrink-0 items-center justify-center rounded-full active:opacity-70"
|
||||
hover-class="wot-bg-bg"
|
||||
hover-class="none"
|
||||
@click="navigateTo('setting')"
|
||||
>
|
||||
<wd-icon name="settings" size="20px" custom-class="wot-text-text-secondary" />
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 邀请好友(获客入口:转发 / 邀请海报) -->
|
||||
<view class="mx-3 mb-3 flex items-center gap-3 rounded-2 p-4 wot-bg-filled-oppo">
|
||||
<view class="h-10 w-10 flex shrink-0 items-center justify-center rounded-xl" style="background-color: #4F8CFF1a;">
|
||||
<wd-icon name="share" size="20px" color="#4F8CFF" />
|
||||
</view>
|
||||
<view class="min-w-0 flex-1">
|
||||
<view class="text-3.5 font-bold wot-text-text-main">
|
||||
邀请好友
|
||||
</view>
|
||||
<view class="mt-0.5 truncate text-2.5 wot-text-text-auxiliary">
|
||||
分享小程序给同事,一起高效办公
|
||||
</view>
|
||||
</view>
|
||||
<!-- 微信原生转发按钮(useShare 已配置分享卡片) -->
|
||||
<button class="invite-share-btn" open-type="share" size="mini">
|
||||
立即邀请
|
||||
</button>
|
||||
<view class="flex shrink-0 flex-col items-center gap-1" @click="handleGeneratePoster">
|
||||
<view class="flex h-8 w-8 items-center justify-center rounded-lg" style="background-color: #10B9811a;">
|
||||
<wd-icon v-if="!posterLoading" name="picture" size="16px" color="#10B981" />
|
||||
<wd-loading v-else :size="16" />
|
||||
</view>
|
||||
<text class="text-2 wot-text-text-auxiliary">
|
||||
海报
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 工单统计 -->
|
||||
<view class="mx-3 mb-3">
|
||||
<wd-row :gutter="12">
|
||||
@@ -115,7 +200,7 @@ const quickLinks = [
|
||||
{{ pendingTickets ?? '-' }}
|
||||
</view>
|
||||
<view class="mt-1 text-3 wot-text-text-secondary">
|
||||
待处理工单
|
||||
{{ $t('mine.pendingTickets') }}
|
||||
</view>
|
||||
</view>
|
||||
</wd-col>
|
||||
@@ -125,7 +210,7 @@ const quickLinks = [
|
||||
{{ processingTickets ?? '-' }}
|
||||
</view>
|
||||
<view class="mt-1 text-3 wot-text-text-secondary">
|
||||
处理中工单
|
||||
{{ $t('mine.processingTickets') }}
|
||||
</view>
|
||||
</view>
|
||||
</wd-col>
|
||||
@@ -135,7 +220,7 @@ const quickLinks = [
|
||||
{{ doneTickets ?? '-' }}
|
||||
</view>
|
||||
<view class="mt-1 text-3 wot-text-text-secondary">
|
||||
已完成工单
|
||||
{{ $t('mine.doneTickets') }}
|
||||
</view>
|
||||
</view>
|
||||
</wd-col>
|
||||
@@ -168,31 +253,75 @@ const quickLinks = [
|
||||
<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('mine.settings') }}
|
||||
</text>
|
||||
</view>
|
||||
<wd-cell-group border custom-class="rounded-2! overflow-hidden">
|
||||
<wd-cell
|
||||
v-for="item in settingsList"
|
||||
:key="item.name"
|
||||
:title="item.title"
|
||||
:title="$t(item.titleKey)"
|
||||
is-link
|
||||
@click="navigateTo(item.name)"
|
||||
/>
|
||||
<wd-cell title="通知设置" :is-link="false">
|
||||
<wd-switch v-model="notifyEnabled" size="22px" />
|
||||
</wd-cell>
|
||||
</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="showPoster" position="center" round custom-style="width: 80%; padding: 32rpx; text-align: center;">
|
||||
<view class="flex flex-col items-center">
|
||||
<view class="mb-2 text-4 font-bold wot-text-text-main">
|
||||
邀请海报
|
||||
</view>
|
||||
<view class="mb-4 text-2.5 wot-text-text-auxiliary">
|
||||
长按识别小程序码,或保存图片分享给好友
|
||||
</view>
|
||||
<image
|
||||
v-if="posterUrl"
|
||||
:src="posterUrl"
|
||||
mode="aspectFit"
|
||||
class="h-96 w-96 rounded-2"
|
||||
show-menu-by-longpress
|
||||
/>
|
||||
<wd-button
|
||||
class="mt-6 w-full"
|
||||
type="primary"
|
||||
round
|
||||
@click="handleSavePoster"
|
||||
>
|
||||
保存到相册
|
||||
</wd-button>
|
||||
</view>
|
||||
</wd-popup>
|
||||
|
||||
<!-- Bottom safe area -->
|
||||
<wd-gap height="100rpx" safe-area-bottom />
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
/* 微信原生转发按钮样式重置(button 默认边框/背景去除,视觉对齐 wd-button mini) */
|
||||
.invite-share-btn {
|
||||
flex-shrink: 0;
|
||||
margin: 0;
|
||||
padding: 0 24rpx;
|
||||
height: 56rpx;
|
||||
line-height: 56rpx;
|
||||
font-size: 24rpx;
|
||||
color: #FFFFFF;
|
||||
background: linear-gradient(135deg, #4F8CFF, #2563EB);
|
||||
border-radius: 9999rpx;
|
||||
border: none;
|
||||
|
||||
&::after {
|
||||
border: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,267 @@
|
||||
<script setup lang="ts">
|
||||
import { Storage } from '@/utils/storage'
|
||||
|
||||
definePage({
|
||||
name: 'onboarding',
|
||||
style: {
|
||||
navigationBarTitleText: '',
|
||||
navigationStyle: 'custom',
|
||||
disableScroll: true,
|
||||
},
|
||||
})
|
||||
|
||||
const ONBOARDING_KEY = 'onboarding_completed'
|
||||
|
||||
const currentIndex = ref(0)
|
||||
|
||||
interface Slide {
|
||||
icon: string
|
||||
color: string
|
||||
bgColor: string
|
||||
title: string
|
||||
subtitle: string
|
||||
desc: string
|
||||
}
|
||||
|
||||
const slides: Slide[] = [
|
||||
{
|
||||
icon: 'dashboard',
|
||||
color: '#4F8CFF',
|
||||
bgColor: 'linear-gradient(135deg, #4F8CFF 0%, #2563EB 100%)',
|
||||
title: '工作台概览',
|
||||
subtitle: '一屏掌握全局动态',
|
||||
desc: '实时查看注册用户、在线用户、今日登录等核心指标,近7天登录趋势一目了然',
|
||||
},
|
||||
{
|
||||
icon: 'message',
|
||||
color: '#F59E0B',
|
||||
bgColor: 'linear-gradient(135deg, #F59E0B 0%, #D97706 100%)',
|
||||
title: '工单管理',
|
||||
subtitle: '高效协作不遗漏',
|
||||
desc: '待处理、处理中、已完成全状态追踪,支持评论互动与附件查看,移动办公更高效',
|
||||
},
|
||||
{
|
||||
icon: 'interaction',
|
||||
color: '#06B6D4',
|
||||
bgColor: 'linear-gradient(135deg, #06B6D4 0%, #0891B2 100%)',
|
||||
title: 'AI 智能助手',
|
||||
subtitle: '随时答疑解惑',
|
||||
desc: '内置 AI 对话助手,支持多会话管理、流式回复、Markdown 渲染,智能辅助日常决策',
|
||||
},
|
||||
{
|
||||
icon: 'notification',
|
||||
color: '#10B981',
|
||||
bgColor: 'linear-gradient(135deg, #10B981 0%, #059669 100%)',
|
||||
title: '消息通知',
|
||||
subtitle: '重要信息不遗漏',
|
||||
desc: '系统公告、工单提醒实时推送,订阅消息让你第一时间掌握动态',
|
||||
},
|
||||
]
|
||||
|
||||
function handleNext() {
|
||||
if (currentIndex.value < slides.length - 1) {
|
||||
currentIndex.value++
|
||||
}
|
||||
else {
|
||||
handleFinish()
|
||||
}
|
||||
}
|
||||
|
||||
function handleSkip() {
|
||||
handleFinish()
|
||||
}
|
||||
|
||||
function handleFinish() {
|
||||
Storage.set(ONBOARDING_KEY, true)
|
||||
uni.reLaunch({ url: '/pages/index/index' })
|
||||
}
|
||||
|
||||
function handleSwiperChange(e: any) {
|
||||
currentIndex.value = e.detail.current
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view class="onboarding-page">
|
||||
<!-- 跳过按钮 -->
|
||||
<view class="skip-btn" @click="handleSkip">
|
||||
<text>跳过</text>
|
||||
</view>
|
||||
|
||||
<!-- 轮播内容 -->
|
||||
<swiper
|
||||
class="onboarding-swiper"
|
||||
:current="currentIndex"
|
||||
:indicator-dots="false"
|
||||
:autoplay="false"
|
||||
:circular="false"
|
||||
@change="handleSwiperChange"
|
||||
>
|
||||
<swiper-item v-for="(slide, i) in slides" :key="i">
|
||||
<view class="slide-item">
|
||||
<!-- 图标区域 -->
|
||||
<view class="slide-icon-wrapper" :style="{ background: slide.bgColor }">
|
||||
<wd-icon :name="slide.icon" size="72px" color="#FFFFFF" />
|
||||
</view>
|
||||
|
||||
<!-- 文字区域 -->
|
||||
<view class="slide-content">
|
||||
<text class="slide-title">
|
||||
{{ slide.title }}
|
||||
</text>
|
||||
<text class="slide-subtitle">
|
||||
{{ slide.subtitle }}
|
||||
</text>
|
||||
<text class="slide-desc">
|
||||
{{ slide.desc }}
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
</swiper-item>
|
||||
</swiper>
|
||||
|
||||
<!-- 指示器 -->
|
||||
<view class="onboarding-dots">
|
||||
<view
|
||||
v-for="(slide, i) in slides"
|
||||
:key="i"
|
||||
class="onboarding-dot"
|
||||
:class="{ 'is-active': i === currentIndex }"
|
||||
/>
|
||||
</view>
|
||||
|
||||
<!-- 操作按钮 -->
|
||||
<view class="onboarding-actions">
|
||||
<wd-button
|
||||
type="primary"
|
||||
round
|
||||
block
|
||||
size="large"
|
||||
@click="handleNext"
|
||||
>
|
||||
{{ currentIndex === slides.length - 1 ? '立即体验' : '下一步' }}
|
||||
</wd-button>
|
||||
</view>
|
||||
|
||||
<!-- 底部安全区 -->
|
||||
<view class="safe-area-bottom" />
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.onboarding-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
background: var(--bg-color-1, #F9F9F9);
|
||||
position: relative;
|
||||
|
||||
.wot-theme-dark & {
|
||||
background: var(--bg-color-1, #0F0F11);
|
||||
}
|
||||
}
|
||||
|
||||
.skip-btn {
|
||||
position: absolute;
|
||||
top: calc(88rpx + env(safe-area-inset-top));
|
||||
right: 32rpx;
|
||||
z-index: 10;
|
||||
padding: 12rpx 24rpx;
|
||||
font-size: 28rpx;
|
||||
color: var(--text-color-3, #6B7280);
|
||||
|
||||
.wot-theme-dark & {
|
||||
color: var(--text-color-3, #9CA3AF);
|
||||
}
|
||||
}
|
||||
|
||||
.onboarding-swiper {
|
||||
flex: 1;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.slide-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
padding: 0 64rpx;
|
||||
}
|
||||
|
||||
.slide-icon-wrapper {
|
||||
width: 200rpx;
|
||||
height: 200rpx;
|
||||
border-radius: 48rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-bottom: 64rpx;
|
||||
box-shadow: 0 16rpx 48rpx rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
|
||||
.slide-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.slide-title {
|
||||
font-size: 48rpx;
|
||||
font-weight: 700;
|
||||
color: var(--text-color, #0A1628);
|
||||
margin-bottom: 16rpx;
|
||||
|
||||
.wot-theme-dark & {
|
||||
color: #FFFFFF;
|
||||
}
|
||||
}
|
||||
|
||||
.slide-subtitle {
|
||||
font-size: 32rpx;
|
||||
font-weight: 500;
|
||||
color: var(--primary-color, #4F8CFF);
|
||||
margin-bottom: 24rpx;
|
||||
}
|
||||
|
||||
.slide-desc {
|
||||
font-size: 28rpx;
|
||||
line-height: 1.6;
|
||||
color: var(--text-color-3, #6B7280);
|
||||
max-width: 560rpx;
|
||||
|
||||
.wot-theme-dark & {
|
||||
color: var(--text-color-3, #9CA3AF);
|
||||
}
|
||||
}
|
||||
|
||||
.onboarding-dots {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 12rpx;
|
||||
padding: 24rpx 0;
|
||||
}
|
||||
|
||||
.onboarding-dot {
|
||||
width: 12rpx;
|
||||
height: 12rpx;
|
||||
border-radius: 6rpx;
|
||||
background: rgba(128, 128, 128, 0.25);
|
||||
transition: all 0.3s ease;
|
||||
|
||||
&.is-active {
|
||||
width: 32rpx;
|
||||
background: var(--primary-color, #4F8CFF);
|
||||
}
|
||||
}
|
||||
|
||||
.onboarding-actions {
|
||||
padding: 0 64rpx 24rpx;
|
||||
}
|
||||
|
||||
.safe-area-bottom {
|
||||
height: env(safe-area-inset-bottom);
|
||||
}
|
||||
</style>
|
||||
@@ -1,7 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { getTicketStats } from '@/composables/useCachedRequest'
|
||||
import { useShare } from '@/composables/useShare'
|
||||
import { useUserStore } from '@/store/userStore'
|
||||
|
||||
useShare({
|
||||
title: 'FastapiAdmin 工作台 - 通知公告 / 工单管理 / AI 助手',
|
||||
path: '/pages/work/index',
|
||||
})
|
||||
|
||||
definePage({
|
||||
name: 'work',
|
||||
layout: 'tabbar',
|
||||
@@ -15,39 +22,37 @@ 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 = [
|
||||
{
|
||||
title: '系统管理',
|
||||
color: 'var(--primary-color)',
|
||||
bg: 'var(--primary-color-light)',
|
||||
items: [
|
||||
{ icon: 'user', text: '用户管理', name: 'work-users' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '业务中心',
|
||||
color: '#F59E0B',
|
||||
bg: 'var(--warning-color-light)',
|
||||
items: [
|
||||
{ icon: 'notification', text: '通知公告', name: 'work-notices' },
|
||||
{ icon: 'message', text: '工单管理', name: 'work-tickets' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '运维监控',
|
||||
color: '#10B981',
|
||||
bg: 'var(--success-color-light)',
|
||||
items: [
|
||||
{ icon: 'file', text: '操作日志', name: 'work-oplogs' },
|
||||
{ icon: 'eye-fill', text: '登录日志', name: 'work-loginlogs' },
|
||||
{ icon: 'user', text: '在线用户', name: 'work-online' },
|
||||
{ icon: 'notification', text: '通知公告', name: 'work-notices', color: '#10B981' },
|
||||
{ icon: 'message', text: '工单管理', name: 'work-tickets', color: '#F59E0B' },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -74,7 +79,7 @@ const filteredGroups = computed(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view class="box-border min-h-screen py-3">
|
||||
<view class="page-wraper py-3">
|
||||
<!-- 用户信息卡 -->
|
||||
<view class="mx-3 mb-4 flex items-center gap-4 rounded-3 px-5 py-6 wot-bg-filled-oppo">
|
||||
<wd-avatar
|
||||
|
||||
Reference in New Issue
Block a user