chore: 初始化项目基础结构与配置

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