refactor: 小程序端代码瘦身与功能补齐

- 清理冗余代码与配置:
  - 删除废弃的 mock 适配器、通用 mock 模块与示例API
  - 移除冗余的 ts 类型文件与 patch 目录
  - 简化 tsconfig 组件路径配置
- 新增功能与优化:
  - 新增全局水印组件与水印配置
  - 接入微信小程序登录/手机号登录
  - 新增系统参数配置 store 与国际化支持
  - 新增 markdown 渲染插件与样式配置
  - 优化 pinia 持久化逻辑修复
  - 新增微信小程序 access_token 缓存配置
  - 修复登录趋势数据结构定义
  - 优化全局请求防抖重定向逻辑
- 页面与路由优化:
  - 补全引导页首次启动逻辑
  - 统一页面底部安全区适配
  - 更新页面路由类型声明
- 代码质量优化:
  - 移除无用 console.log 调试代码
  - 修复全局弹窗重复重登录问题
  - 优化 mp-html 组件配置
This commit is contained in:
zhangtao
2026-08-05 23:47:49 +08:00
parent 372b277a9f
commit ffbe2453f2
126 changed files with 5288 additions and 12246 deletions
+144 -110
View File
@@ -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>
+180 -110
View File
@@ -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>
+163 -34
View File
@@ -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>
+267
View File
@@ -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>
+26 -21
View File
@@ -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