chore: 初始化uniapp前端项目基础框架

新增项目配置、静态资源、工具类、状态管理、API接口、组件库集成等全套基础代码,完成项目初始化搭建
This commit is contained in:
zhangtao
2026-08-02 03:49:27 +08:00
parent 7f8320d3ed
commit 3c9b77f994
278 changed files with 55387 additions and 2 deletions
+506
View File
@@ -0,0 +1,506 @@
<script setup lang="ts">
import type { DashboardStats } from '@/api/module_monitor/dashboard'
import type { NoticeItem } from '@/api/module_system/notice'
import { onPullDownRefresh, onReady, onShow } from '@dcloudio/uni-app'
import { ref } from 'vue'
import { DashboardAPI } from '@/api/module_monitor/dashboard'
import { NoticeAPI } from '@/api/module_system/notice'
import { TicketAPI } from '@/api/module_system/ticket'
definePage({
name: 'home',
layout: 'tabbar',
style: { navigationBarTitleText: '工作台概览' },
})
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 NAV_LIST = [
{ icon: '/static/icons/user.png', title: '用户管理', name: 'work-users' },
{ icon: '/static/icons/role.png', title: '角色管理', name: 'work-roles' },
{ icon: '/static/icons/notice.png', title: '通知公告', name: 'work-notices' },
{ icon: '/static/icons/setting.png', title: '系统配置', name: 'work-params' },
]
function navigateTo(name: string) {
const r = getRouter()
if (r)
r.push({ name })
}
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
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 weekDays = ['日', '一', '二', '三', '四', '五', '六']
return `${now.getFullYear()}年${now.getMonth() + 1}月${now.getDate()}日 星期${weekDays[now.getDay()]}`
}
function getGreeting() {
const hour = new Date().getHours()
if (hour < 6)
return '夜深了'
if (hour < 9)
return '早上好'
if (hour < 12)
return '上午好'
if (hour < 14)
return '中午好'
if (hour < 18)
return '下午好'
return '晚上好'
}
</script>
<template>
<view class="home-page">
<!-- Navigation bar -->
<view class="home-nav">
<text class="home-nav__title">
工作台概览
</text>
<view class="home-nav__bell">
<text class="home-nav__bell-icon" @click="navigateTo('work-notices')">
🔔
</text>
<view v-if="pendingTickets > 0" class="home-nav__badge" />
</view>
</view>
<!-- Greeting card -->
<view class="greeting-card fade-in-up">
<text class="greeting-card__text">
{{ getGreeting() }},管理员
</text>
<text class="greeting-card__date">
{{ getDateString() }}
</text>
</view>
<!-- Dashboard entry -->
<view class="dashboard-entry card-pressable fade-in-up-1" @click="navigateTo('dashboard')">
<view class="dashboard-entry__icon">
<text class="dashboard-entry__icon-text">
📊
</text>
</view>
<view class="dashboard-entry__info">
<text class="dashboard-entry__title">
数据概览
</text>
<text class="dashboard-entry__sub">
查看系统运行状态与趋势分析
</text>
</view>
<text class="dashboard-entry__arrow">
›
</text>
</view>
<!-- Quick nav -->
<view class="home-grid home-grid--nav">
<wd-grid :column="4" clickable>
<wd-grid-item v-for="(item, i) in NAV_LIST" :key="i" @click="navigateTo(item.name)">
<image class="home-nav-icon" :src="item.icon" mode="aspectFit" />
<text class="home-grid__label">
{{ item.title }}
</text>
</wd-grid-item>
</wd-grid>
</view>
<!-- Stats -->
<view class="section-header">
<text class="section-header__title">
运营概览
</text>
</view>
<SkeletonPage v-if="loading && !dashboardStats" :rows="3" />
<template v-else>
<view class="stats-row">
<view class="stat-item card-pressable fade-in-up-1">
<text class="stat-item__value stat-item__value--primary count-up">
{{ dashboardStats?.total_users ?? '-' }}
</text>
<text class="stat-item__label">
注册用户
</text>
<text class="stat-item__sub">
本周新增 {{ dashboardStats?.week_user_created ?? 0 }}
</text>
</view>
<view class="stat-item card-pressable fade-in-up-2">
<text class="stat-item__value stat-item__value--success count-up">
{{ dashboardStats?.online_users ?? '-' }}
</text>
<text class="stat-item__label">
在线用户
</text>
<text class="stat-item__sub">
当前在线
</text>
</view>
<view class="stat-item card-pressable fade-in-up-3">
<text class="stat-item__value stat-item__value--warning count-up">
{{ dashboardStats?.today_login_count ?? '-' }}
</text>
<text class="stat-item__label">
今日登录
</text>
<text class="stat-item__sub">
{{ dashboardStats?.today_unique_users ?? 0 }} 独立用户
</text>
</view>
</view>
<!-- Pending ticket alert -->
<view v-if="pendingTickets > 0" class="alert-bar btn-press fade-in-up-4" @click="navigateTo('work-tickets')">
<view class="alert-bar__dot" />
<text class="alert-bar__text">
您有 {{ pendingTickets }} 个待处理工单,点击查看 →
</text>
</view>
<!-- Recent notices -->
<view v-if="recentNotices.length > 0" class="home-section">
<view class="section-header">
<text class="section-header__title">
最新公告
</text>
<text class="section-header__more" @click="navigateTo('work-notices')">
全部 →
</text>
</view>
<view class="home-card">
<wd-cell-group border>
<wd-cell v-for="item in recentNotices" :key="item.id" :title="item.notice_title" is-link @click="navigateTo('work-notices')">
<template #label>
<text class="home-cell__time">
{{ item.created_time || '' }}
</text>
</template>
</wd-cell>
</wd-cell-group>
</view>
</view>
<!-- Recent logins -->
<view v-if="dashboardStats?.recent_logins?.length" class="home-section">
<view class="section-header">
<text class="section-header__title">
最近登录
</text>
</view>
<view class="home-card">
<wd-cell-group border>
<wd-cell v-for="(item, i) in dashboardStats.recent_logins" :key="i" :title="item.username">
<template #label>
<text class="home-cell__time">
{{ item.login_time || '' }}{{ item.login_ip ? ` · ${item.login_ip}` : '' }}
</text>
</template>
<StatusBadge :status="item.status === 1 ? 'success' : 'failed'" />
</wd-cell>
</wd-cell-group>
</view>
</view>
</template>
<!-- Bottom safe area -->
<view style="height: 100rpx;" />
</view>
</template>
<style lang="scss" scoped>
.home-page {
padding: 0 32rpx;
padding-bottom: calc(120rpx + env(safe-area-inset-bottom));
background: var(--page-bg-color, #F9F9F9);
min-height: 100vh;
}
/* ===== Navigation ===== */
.home-nav {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16rpx 0 24rpx;
&__title {
font-size: var(--font-2xl, 40rpx);
font-weight: 600;
color: var(--text-color, #0A1628);
}
&__bell {
position: relative;
width: 72rpx;
height: 72rpx;
border-radius: 50%;
background: var(--bg-color-2, #F5F6F8);
display: flex;
align-items: center;
justify-content: center;
&-icon { font-size: var(--font-lg, 32rpx); }
}
&__badge {
position: absolute;
top: 14rpx;
right: 14rpx;
width: 16rpx;
height: 16rpx;
border-radius: 50%;
background: var(--danger-color, #EF4444);
border: 2rpx solid var(--bg-color, #FFFFFF);
}
}
/* ===== Greeting ===== */
.greeting-card {
background: var(--gradient-primary, linear-gradient(135deg, #4F8CFF, #2563EB));
border-radius: 32rpx;
padding: 40rpx 32rpx;
margin-bottom: 32rpx;
box-shadow: var(--shadow-lg, 0 12rpx 40rpx rgba(1, 77, 178, 0.15));
&__text {
display: block;
font-size: var(--font-2xl, 40rpx);
font-weight: 600;
color: #FFFFFF;
margin-bottom: 8rpx;
}
&__date {
font-size: var(--font-sm, 24rpx);
color: rgba(255, 255, 255, 0.80);
}
}
/* ===== Dashboard entry ===== */
.dashboard-entry {
display: flex;
align-items: center;
gap: 20rpx;
background: linear-gradient(135deg, #F0F5FF, #E8F0FE);
border: 2rpx solid rgba(1, 77, 178, 0.08);
border-radius: 24rpx;
padding: 28rpx;
margin-bottom: 32rpx;
transition: all 0.15s ease;
&__icon {
width: 80rpx;
height: 80rpx;
border-radius: 20rpx;
background: linear-gradient(135deg, var(--primary-color, #4F8CFF), var(--primary-color-dark, #2970FF));
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
&-text { font-size: var(--font-xl, 36rpx); }
}
&__info {
flex: 1;
display: flex;
flex-direction: column;
gap: 4rpx;
}
&__title {
font-size: var(--font-lg, 32rpx);
font-weight: 600;
color: var(--text-color, #0A1628);
}
&__sub {
font-size: var(--font-sm, 24rpx);
color: var(--text-color-3, #6B7280);
}
&__arrow {
font-size: var(--font-2xl, 40rpx);
font-weight: 300;
color: var(--primary-color, #4F8CFF);
font-family: 'Inter', -apple-system, sans-serif;
}
}
/* ===== Quick nav grid ===== */
.home-grid {
&--nav {
background: var(--card-bg-color, #FFFFFF);
border-radius: 24rpx;
padding: 16rpx 0;
margin-bottom: 32rpx;
box-shadow: var(--shadow-xs, 0 3rpx 8rpx rgba(1, 77, 178, 0.03));
}
&__label {
font-size: var(--font-sm, 24rpx);
color: var(--text-color-3, #6B7280);
}
}
.home-nav-icon { width: 64rpx; height: 64rpx; border-radius: 12rpx; }
/* ===== Section header ===== */
.section-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 24rpx;
&__title {
font-size: var(--font-lg, 32rpx);
font-weight: 600;
color: var(--text-color, #0A1628);
}
&__more {
font-size: var(--font-sm, 24rpx);
color: var(--primary-color, #4F8CFF);
}
}
/* ===== Stats row ===== */
.stats-row {
display: flex;
gap: 24rpx;
margin-bottom: 24rpx;
}
.stat-item {
flex: 1;
background: var(--card-bg-color, #FFFFFF);
border-radius: 24rpx;
padding: 24rpx;
box-shadow: var(--shadow-xs, 0 3rpx 8rpx rgba(79, 140, 255, 0.03));
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
transition: transform var(--transition-fast), box-shadow var(--transition-fast);
&__value {
font-size: var(--font-3xl, 48rpx);
font-weight: 600;
font-family: 'Inter', -apple-system, sans-serif;
margin-bottom: 8rpx;
&--primary { color: var(--primary-color, #4F8CFF); }
&--success { color: var(--success-color, #10B981); }
&--warning { color: var(--warning-color, #F59E0B); }
}
&__label {
font-size: var(--font-sm, 24rpx);
color: var(--text-color-3, #6B7280);
margin-bottom: 4rpx;
}
&__sub {
font-size: var(--font-xs, 20rpx);
color: var(--text-color-4, #B0B0B0);
}
}
/* ===== Alert bar ===== */
.alert-bar {
display: flex;
align-items: center;
gap: 16rpx;
background: var(--warning-color-light, #FFF8E6);
border-radius: 24rpx;
padding: 24rpx;
margin-bottom: 32rpx;
&__dot {
width: 12rpx;
height: 12rpx;
border-radius: 50%;
background: var(--warning-color, #F59E0B);
flex-shrink: 0;
}
&__text {
font-size: var(--font-md, 28rpx);
color: var(--warning-color, #F59E0B);
}
}
/* ===== Home card ===== */
.home-section {
margin-bottom: 32rpx;
}
.home-card {
background: var(--card-bg-color, #FFFFFF);
border-radius: 24rpx;
box-shadow: var(--shadow-xs, 0 3rpx 8rpx rgba(1, 77, 178, 0.03));
overflow: hidden;
}
/* ===== Home cell time ===== */
.home-cell__time {
font-size: var(--font-sm, 24rpx);
color: var(--text-color-4, #B0B0B0);
}
</style>
+611
View File
@@ -0,0 +1,611 @@
<script lang="ts" setup>
import type { CaptchaInfo, LoginFormData, OAuthProvider } from '@/api/module_system/auth'
import type { FormSchema } from '@wot-ui/ui/components/wd-form/types'
import { onLoad } from '@dcloudio/uni-app'
import { reactive, ref } from 'vue'
import AuthAPI from '@/api/module_system/auth'
import { REMEMBER_ME_KEY } from '@/constants'
import { useUserStore } from '@/store/userStore'
import { logger } from '@/utils/logger'
import { Storage } from '@/utils/storage'
definePage({ name: 'login', style: { navigationBarTitleText: '登录' } })
const loginFormRef = ref()
const sliderCaptchaRef = ref()
const loading = ref(false)
const userStore = useUserStore()
const redirect = ref('/pages/index/index')
/** 表单验证 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: '请输入用户名' })
else if (username.length < 3 || username.length > 20)
errors.push({ path: ['username'], message: '用户名长度为 3-20 个字符' })
if (!password)
errors.push({ path: ['password'], message: '请输入密码' })
else if (password.length < 6 || password.length > 20)
errors.push({ path: ['password'], message: '密码长度为 6-20 个字符' })
return errors
},
}
const loginFormData = reactive<LoginFormData>({
username: '',
password: '',
captcha: '',
captcha_key: '',
remember: true,
login_type: '移动端',
})
const captchaState = reactive<CaptchaInfo>({ enable: false, key: '', img_base: '' })
/**
* 滑块验证状态:
* - idle 待验证(初始/重置后)
* - pending 验证中(已拖动到终点,正在请求后端)
* - success 验证成功(后端返回 verified=true)
* - fail 验证失败(后端返回错误或网络异常)
*/
const sliderStatus = ref<'idle' | 'pending' | 'success' | 'fail'>('idle')
/** 从本地存储恢复记住的用户名(仅用户名,不存储密码) */
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
// 重置滑块到初始状态
sliderStatus.value = 'idle'
sliderCaptchaRef.value?.reset?.()
}
}
else {
captchaState.enable = false
}
}
catch (e) {
logger.error('验证码获取失败', e)
captchaState.enable = false
}
}
/**
* 滑块拖动到终点时触发 — 调用后端 slider_complete 接口标记验证完成
* 后端仅标记 captcha_key 状态为 verified,不校验 x 坐标
*/
async function handleSliderVerify() {
if (!captchaState.key) {
sliderStatus.value = 'fail'
uni.showToast({ title: '验证码已过期,请刷新', icon: 'none' })
return
}
sliderStatus.value = 'pending'
try {
const result = await AuthAPI.completeSliderCaptcha({
captcha_key: captchaState.key,
x: 100, // 占位值,后端未使用
})
if (result?.verified) {
sliderStatus.value = 'success'
loginFormData.captcha = 'verified' // 占位值,后端只校验 captcha_key 状态
}
else {
sliderStatus.value = 'fail'
uni.showToast({ title: '验证失败,请重试', icon: 'none' })
}
}
catch (e) {
logger.error('滑块验证失败', e)
sliderStatus.value = 'fail'
uni.showToast({ title: getErrorMessage(e, '验证失败,请重试'), icon: 'none' })
}
}
/** 滑块重置时清空占位验证值 */
function handleSliderReset() {
loginFormData.captcha = ''
}
onLoad((options) => {
const from = options?.redirect ? decodeURIComponent(options.redirect) : ''
// 安全验证:仅允许本地页面路径,防止开放重定向攻击
if (from && from !== '/pages/login/index' && from.startsWith('/pages/')) {
redirect.value = from
}
restoreRememberedUser()
getLoginCaptcha()
})
/** 登录提交 — 防抖 + 字段验证 + 滑块校验 + 错误提示 + 记住密码 */
async function handleSubmit() {
if (loading.value)
return
// 滑块验证前置校验
if (captchaState.enable && sliderStatus.value !== 'success') {
uni.showToast({ title: '请先完成滑块验证', 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 (e) {
// 全局错误提示 — 优先使用后端返回的具体错误信息(兼容 Error / 对象 data.message)
const errMsg = getErrorMessage(e) || (e as { data?: { message?: string } })?.data?.message || '登录失败,请检查账号密码'
uni.showToast({ title: errMsg, icon: 'none', duration: 2500 })
// 登录失败后自动刷新验证码并重置滑块
if (captchaState.enable)
getLoginCaptcha()
}
finally {
loading.value = false
}
}
/** 第三方 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: '链接已复制,请在浏览器中打开', icon: 'none' })
},
})
// #endif
}
}
catch (e) {
uni.showToast({ title: getErrorMessage(e, 'OAuth登录失败'), icon: 'none' })
}
}
</script>
<template>
<view class="login-page">
<!-- Brand area -->
<view class="login-brand fade-in-up">
<image class="brand-logo" src="/static/logo.png" mode="aspectFit" />
<text class="brand-title">
FastapiAdmin
</text>
<text class="brand-subtitle">
高效的企业管理移动平台
</text>
</view>
<!-- Form card -->
<view class="login-card bounce-in">
<text class="login-card__title">
账号登录
</text>
<wd-form ref="loginFormRef" :model="loginFormData" :schema="loginSchema">
<!-- Username -->
<wd-form-item prop="username">
<view class="login-field">
<wd-input
v-model="loginFormData.username"
placeholder="请输入用户名"
clearable
confirm-type="next"
@confirm="handleSubmit"
>
<template #prefix>
<wd-icon name="user" size="20rpx" color="#9CA3AF" />
</template>
</wd-input>
</view>
</wd-form-item>
<!-- Password — show-password 自动启用密码可见性切换 -->
<wd-form-item prop="password">
<view class="login-field">
<wd-input
v-model="loginFormData.password"
placeholder="请输入密码"
show-password
clearable
confirm-type="go"
@confirm="handleSubmit"
>
<template #prefix>
<wd-icon name="lock" size="20rpx" color="#9CA3AF" />
</template>
</wd-input>
</view>
</wd-form-item>
<!-- Slider Captcha — 滑块拖动验证(条件显示) -->
<wd-form-item v-if="captchaState.enable">
<SliderCaptcha
ref="sliderCaptchaRef"
:status="sliderStatus"
@verify="handleSliderVerify"
@reset="handleSliderReset"
/>
</wd-form-item>
<!-- 记住密码 + 忘记密码 -->
<view class="login-options">
<view class="remember-check" @click="loginFormData.remember = !loginFormData.remember">
<view class="remember-check__box" :class="{ 'is-checked': loginFormData.remember }">
<text v-if="loginFormData.remember" class="remember-check__tick">
✓
</text>
</view>
<text class="remember-check__label">
记住密码
</text>
</view>
<text class="forgot-link">
忘记密码?
</text>
</view>
<!-- Submit -->
<wd-button
type="primary"
block
:loading="loading"
round
@click="handleSubmit"
>
{{ loading ? '登录中...' : '登 录' }}
</wd-button>
</wd-form>
</view>
<!-- OAuth Login -->
<view class="oauth-section fade-in-up-3">
<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">
<!-- WeChat -->
<view class="oauth-btn" @click="handleOAuth('wechat')">
<view class="oauth-btn__icon" style="background: #07C160">
<image class="oauth-btn__iconify" src="/static/icons/oauth/wechat.svg" />
</view>
<text class="oauth-btn__label">
微信
</text>
</view>
<!-- GitHub -->
<view class="oauth-btn" @click="handleOAuth('github')">
<view class="oauth-btn__icon" style="background: #24292F">
<image class="oauth-btn__iconify" src="/static/icons/oauth/github.svg" />
</view>
<text class="oauth-btn__label">
GitHub
</text>
</view>
<!-- Gitee -->
<view class="oauth-btn" @click="handleOAuth('gitee')">
<view class="oauth-btn__icon" style="background: #C71D23">
<image class="oauth-btn__iconify" src="/static/icons/oauth/gitee.svg" />
</view>
<text class="oauth-btn__label">
Gitee
</text>
</view>
<!-- QQ -->
<view class="oauth-btn" @click="handleOAuth('qq')">
<view class="oauth-btn__icon" style="background: #12B7F5">
<image class="oauth-btn__iconify" src="/static/icons/oauth/qq.svg" />
</view>
<text class="oauth-btn__label">
QQ
</text>
</view>
</view>
</view>
<!-- Footer -->
<view class="login-footer">
<text class="login-footer__text">
立即注册 | 游客访问
</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: 0 64rpx;
padding-bottom: calc(48rpx + env(safe-area-inset-bottom));
background: var(--page-bg-color, #F9F9F9);
overflow: hidden;
box-sizing: border-box;
}
/* 暗黑模式下整页背景 + 所有子元素继承深色,消除白色断层 */
:deep(.theme-dark) .login-page,
.login-page:has(.theme-dark),
.theme-dark .login-page {
background: var(--bg-color-1, #0F0F11);
}
/* ===== Brand ===== */
.login-brand {
display: flex;
flex-direction: column;
align-items: center;
padding-top: 72rpx;
padding-bottom: 40rpx;
gap: 12rpx;
flex-shrink: 0;
.brand-logo {
width: 140rpx;
height: 140rpx;
margin-bottom: 4rpx;
}
.brand-title {
font-size: var(--font-3xl, 48rpx);
font-weight: 700;
color: var(--text-color, #0A1628);
/* 暗黑模式下使用纯白,提升品牌标题醒目度 */
:deep(.theme-dark) &,
.theme-dark & {
color: #FFFFFF;
}
}
.brand-subtitle {
font-size: var(--font-md, 28rpx);
color: var(--text-color-3, #6B7280);
/* 暗黑下提亮到 --text-color-2,避免 #9CA3AF 在深底上偏暗 */
:deep(.theme-dark) &,
.theme-dark & {
color: var(--text-color-2, #D1D5DB);
}
}
}
/* ===== Card(不再是毛玻璃,使用纯色背景 + 与页面背景形成层级差) ===== */
.login-card {
width: 100%;
max-width: 654rpx;
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));
margin-bottom: 20rpx;
flex-shrink: 0;
/* 暗黑模式:卡片用深色 2 级,页面背景用深色 1 级,形成细微层级差 */
:deep(.theme-dark) &,
.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;
/* 暗黑模式下使用纯白,提升卡片标题醒目度 */
:deep(.theme-dark) &,
.theme-dark & {
color: #FFFFFF;
}
}
}
/* ===== 表单输入框容器 — 圆角底色包裹,替代 uview 的 border="surround" ===== */
.login-field {
background: var(--bg-color-2, #F5F6F8);
border: 2rpx solid var(--border-color, #EAECF0);
border-radius: 16rpx;
padding: 0 20rpx;
transition: border-color var(--transition-fast, 0.15s ease);
}
/* 暗黑模式适配(wot-ui 输入框主题变量 + 容器背景) */
:deep(.wd-form-item) {
margin-bottom: 20rpx;
}
.theme-dark :deep(.wd-input) {
--wot-input-bg: var(--bg-color-3, #2C2C2E);
--wot-input-inner-color: var(--text-color, #FFFFFF);
--wot-input-inner-placeholder-color: var(--text-color-4, #6B7280);
}
.theme-dark .login-field {
background: var(--bg-color-3, #2C2C2E);
border-color: var(--border-color, #2C2C2E);
}
/* ===== 记住密码 + 忘记密码 ===== */
.login-options {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 28rpx;
margin-top: 4rpx;
}
.remember-check {
display: flex;
align-items: center;
gap: 12rpx;
padding: 8rpx 0;
&__box {
width: 36rpx;
height: 36rpx;
border-radius: 8rpx;
border: 2rpx solid var(--text-color-4, #B0B0B0);
display: flex;
align-items: center;
justify-content: center;
transition: background var(--transition-fast, 0.15s ease), border-color var(--transition-fast, 0.15s ease);
&.is-checked {
background: var(--primary-color, #4F8CFF);
border-color: var(--primary-color, #4F8CFF);
}
}
&__tick {
font-size: 24rpx;
color: #ffffff;
line-height: 1;
}
&__label {
font-size: var(--font-md, 28rpx);
color: var(--text-color-3, #6B7280);
}
}
.forgot-link {
font-size: var(--font-md, 28rpx);
color: var(--primary-color, #4F8CFF);
}
/* ===== OAuth Section ===== */
.oauth-section {
width: 100%;
max-width: 654rpx;
margin-top: 16rpx;
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;
}
}
.oauth-buttons {
display: flex;
justify-content: center;
gap: 48rpx;
}
.oauth-btn {
display: flex;
flex-direction: column;
align-items: center;
gap: 12rpx;
&__icon {
width: 88rpx;
height: 88rpx;
border-radius: 44rpx;
display: flex;
align-items: center;
justify-content: center;
transition: transform var(--transition-fast, 0.15s ease);
}
&:active &__icon {
transform: scale(0.92);
}
&__iconify {
width: 48rpx;
height: 48rpx;
}
&__label {
font-size: var(--font-xs, 20rpx);
color: var(--text-color-3, #6B7280);
}
}
/* ===== Footer ===== */
.login-footer {
display: flex;
flex-direction: column;
align-items: center;
padding: 24rpx 0 0;
flex-shrink: 0;
&__text {
font-size: var(--font-sm, 24rpx);
color: var(--text-color-4, #B0B0B0);
}
}
</style>
+339
View File
@@ -0,0 +1,339 @@
<script lang="ts" setup>
definePage({
name: 'mine',
layout: 'tabbar',
style: { navigationBarTitleText: '我的' },
})
const router = useRouter()
const userStore = useUserStore()
const userInfo = computed(() => userStore.userInfo)
function navigateTo(name: string) {
router.push({ name })
}
function handleLogout() {
uni.showModal({
title: '提示',
content: '确认退出登录吗?',
success(res) {
if (res.confirm) {
userStore.logout()
}
},
})
}
onShow(() => {
const pages = getCurrentPages()
if (pages.length > 0 && pages[pages.length - 1].route === 'pages/mine/index') {
uni.$emit('updateTabbar', 'mine')
}
})
const settingsList = [
{ title: '首页示例', name: 'home-demo' },
{ title: '个人资料', name: 'profile' },
{ title: '修改密码', name: 'account' },
{ title: '通知设置', name: 'settings' },
{ title: '关于我们', name: 'about' },
]
const quickLinks = [
{ title: '用户管理', name: 'work-users', icon: 'user', color: 'var(--primary-color)' },
{ title: '角色管理', name: 'work-roles', icon: 'lock', color: '#F59E0B' },
{ title: '部门管理', name: 'work-depts', icon: 'apps', color: '#10B981' },
{ title: '菜单管理', name: 'work-menus', icon: 'menu', color: '#8B5CF6' },
{ title: '岗位管理', name: 'work-positions', icon: 'list', color: 'var(--primary-color)' },
{ title: '字典管理', name: 'work-dicts', icon: 'tags', color: '#F59E0B' },
{ title: '通知公告', name: 'work-notices', icon: 'notification', color: '#10B981' },
{ title: '参数管理', name: 'work-params', icon: 'settings', color: '#8B5CF6' },
{ title: '定时任务', name: 'work-cronjob', icon: 'clock-circle', color: '#EC4899' },
{ title: '工作流', name: 'work-workflow', icon: 'play-arrow', color: '#8B5CF6' },
{ title: '设备管理', name: 'work-devices', icon: 'phone', color: '#10B981' },
{ title: 'AI 模型', name: 'work-ai-models', icon: 'robot', color: '#8B5CF6' },
]
</script>
<template>
<view class="mine-page">
<!-- Header -->
<view class="mine-header fade-in-up">
<view class="mine-header__avatar">
<text class="mine-header__avatar-text">
{{ (userInfo?.name || '管').charAt(0) }}
</text>
</view>
<text class="mine-header__name">
{{ userInfo?.name || '管理员' }}
</text>
<text class="mine-header__role">
{{ userInfo?.roles?.map(r => r.name).join(', ') || '超级管理员 · 技术部' }}
</text>
</view>
<!-- Stats -->
<view class="mine-stats fade-in-up-1">
<view class="mine-stat-item card-pressable">
<text class="mine-stat-item__value">
1,024
</text>
<text class="mine-stat-item__label">
操作次数
</text>
</view>
<view class="mine-stat-item card-pressable">
<text class="mine-stat-item__value">
365
</text>
<text class="mine-stat-item__label">
登录天数
</text>
</view>
</view>
<!-- Quick links -->
<view class="mine-section fade-in-up-2">
<text class="mine-section__title">
快捷入口
</text>
<view class="quick-grid">
<view
v-for="item in quickLinks" :key="item.name"
class="quick-item"
hover-class="quick-item--hover"
@click="navigateTo(item.name)"
>
<view class="quick-item__icon" :style="{ background: `${item.color}15` }">
<wd-icon :name="item.icon" size="22px" :color="item.color" />
</view>
<text class="quick-item__text">
{{ item.title }}
</text>
</view>
</view>
</view>
<!-- Settings list -->
<view class="mine-section fade-in-up-3">
<text class="mine-section__title">
设置
</text>
<view class="settings-card">
<view
v-for="(item, i) in settingsList" :key="i"
class="settings-item"
hover-class="settings-item--hover"
@click="navigateTo(item.name)"
>
<text class="settings-item__label">
{{ item.title }}
</text>
<text class="settings-item__arrow">
›
</text>
</view>
</view>
</view>
<!-- Logout -->
<view class="mine-section fade-in-up-4">
<view class="logout-btn" hover-class="logout-btn--hover" @click="handleLogout">
<text class="logout-btn__text">
退出登录
</text>
</view>
</view>
<!-- Bottom safe area -->
<view style="height: 100rpx;" />
</view>
</template>
<style lang="scss" scoped>
.mine-page {
padding: 0 32rpx;
padding-bottom: calc(120rpx + env(safe-area-inset-bottom));
background: var(--page-bg-color, #F9F9F9);
min-height: 100vh;
}
/* ===== Header ===== */
.mine-header {
display: flex;
flex-direction: column;
align-items: center;
padding: 40rpx 40rpx 48rpx;
margin: 16rpx 0 32rpx;
background: var(--gradient-primary, linear-gradient(135deg, #4F8CFF, #2563EB));
border-radius: 32rpx;
&__avatar {
width: 112rpx;
height: 112rpx;
border-radius: 50%;
background: rgba(255, 255, 255, 0.20);
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 24rpx;
}
&__avatar-text {
font-size: var(--font-3xl, 48rpx);
font-weight: 600;
color: #FFFFFF;
}
&__name {
font-size: var(--font-2xl, 40rpx);
font-weight: 600;
color: #FFFFFF;
margin-bottom: 8rpx;
}
&__role {
font-size: var(--font-sm, 24rpx);
color: rgba(255, 255, 255, 0.80);
}
}
/* ===== Stats ===== */
.mine-stats {
display: flex;
gap: 24rpx;
margin-bottom: 40rpx;
}
.mine-stat-item {
flex: 1;
background: var(--card-bg-color, #FFFFFF);
border-radius: 24rpx;
padding: 32rpx;
box-shadow: var(--shadow-sm, 0 1rpx 2rpx rgba(1, 77, 178,0.06));
display: flex;
flex-direction: column;
align-items: center;
&__value {
font-size: var(--font-2xl, 40rpx);
font-weight: 600;
color: var(--text-color, #0A1628);
font-family: 'Inter', -apple-system, sans-serif;
margin-bottom: 8rpx;
}
&__label {
font-size: var(--font-sm, 24rpx);
color: var(--text-color-3, #6B7280);
}
}
/* ===== Section ===== */
.mine-section {
margin-bottom: 40rpx;
&__title {
display: block;
font-size: var(--font-lg, 32rpx);
font-weight: 600;
color: var(--text-color, #0A1628);
margin-bottom: 20rpx;
}
}
/* ===== Quick grid ===== */
.quick-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 16rpx;
}
.quick-item {
display: flex;
flex-direction: column;
align-items: center;
gap: 12rpx;
background: var(--card-bg-color, #FFFFFF);
border-radius: 24rpx;
padding: 24rpx 16rpx;
box-shadow: var(--shadow-sm, 0 1rpx 2rpx rgba(1, 77, 178,0.06));
transition: all 0.15s ease;
&--hover {
opacity: 0.85;
transform: scale(0.95);
}
&__icon {
width: 64rpx;
height: 64rpx;
border-radius: 16rpx;
display: flex;
align-items: center;
justify-content: center;
}
&__text {
font-size: var(--font-xs, 20rpx);
color: var(--text-color-3, #6B7280);
text-align: center;
}
}
/* ===== Settings ===== */
.settings-card {
background: var(--card-bg-color, #FFFFFF);
border-radius: 24rpx;
box-shadow: var(--shadow-sm, 0 1rpx 2rpx rgba(1, 77, 178,0.06));
overflow: hidden;
}
.settings-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 32rpx;
&:not(:last-child) {
border-bottom: 1rpx solid var(--border-color-light, #F3F4F6);
}
&--hover {
background: var(--bg-color-2, #F5F6F8);
}
&__label {
font-size: var(--font-lg, 32rpx);
color: var(--text-color, #0A1628);
}
&__arrow {
font-size: var(--font-xl, 36rpx);
color: var(--text-color-4, #B0B0B0);
font-family: 'Inter', -apple-system, sans-serif;
}
}
/* ===== Logout ===== */
.logout-btn {
display: flex;
align-items: center;
justify-content: center;
height: 96rpx;
background: var(--danger-color-light, #FEF2F2);
border-radius: 24rpx;
transition: all 0.15s ease;
&--hover {
opacity: 0.85;
}
&__text {
font-size: var(--font-lg, 32rpx);
font-weight: 500;
color: var(--danger-color, #EF4444);
}
}
</style>
+273
View File
@@ -0,0 +1,273 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useUserStore } from '@/store/userStore'
definePage({
name: 'work',
layout: 'tabbar',
style: { navigationBarTitleText: '工作台' },
})
const router = useRouter()
const userStore = useUserStore()
const userInfo = computed(() => userStore.userInfo)
function navigateTo(name: string) {
router.push({ name })
}
onShow(() => {
const pages = getCurrentPages()
if (pages.length > 0 && pages[pages.length - 1].route === 'pages/work/index') {
uni.$emit('updateTabbar', 'work')
}
})
const groups = [
{
title: '系统管理',
color: 'var(--primary-color)',
bg: 'var(--primary-color-light)',
items: [
{ icon: 'user', text: '用户管理', name: 'work-users' },
{ icon: 'lock', text: '角色管理', name: 'work-roles' },
{ icon: 'apps', text: '部门管理', name: 'work-depts' },
{ icon: 'menu', text: '菜单管理', name: 'work-menus' },
{ icon: 'list', text: '岗位管理', name: 'work-positions' },
],
},
{
title: '系统配置',
color: '#F59E0B',
bg: 'var(--warning-color-light)',
items: [
{ icon: 'tags', text: '字典管理', name: 'work-dicts' },
{ icon: 'notification', text: '通知公告', name: 'work-notices' },
{ icon: 'settings', text: '参数管理', name: 'work-params' },
{ icon: 'refresh', text: '版本管理', name: 'work-versions' },
],
},
{
title: '运维监控',
color: '#10B981',
bg: 'var(--success-color-light)',
items: [
{ icon: 'message', text: '工单管理', name: 'work-tickets' },
{ icon: 'file', text: '操作日志', name: 'work-oplogs' },
{ icon: 'eye-fill', text: '登录日志', name: 'work-loginlogs' },
{ icon: 'user', text: '在线用户', name: 'work-online' },
{ icon: 'dashboard', text: '服务监控', name: 'work-server' },
{ icon: 'refresh', text: '缓存管理', name: 'work-cache' },
{ icon: 'folder', text: '文件管理', name: 'work-file-manager' },
{ icon: 'clock-circle-fill', text: '定时任务', name: 'work-cronjob' },
{ icon: 'phone', text: '设备管理', name: 'work-devices' },
],
},
{
title: '开发者工具',
color: '#8B5CF6',
bg: 'var(--bg-color-2)',
items: [
{ icon: 'message', text: 'AI 助手', name: 'work-chat' },
{ icon: 'robot', text: 'AI 模型', name: 'work-ai-models' },
{ icon: 'file', text: '代码生成', name: 'work-gencode' },
{ icon: 'play-arrow', text: '工作流', name: 'work-workflow' },
],
},
]
</script>
<template>
<view class="work-page">
<!-- Navigation -->
<view class="work-nav">
<text class="work-nav__title">
工作台
</text>
<view class="work-nav__avatar" @click="navigateTo('mine')">
<text class="work-nav__avatar-text">
{{ (userInfo?.name || '管').charAt(0) }}
</text>
</view>
</view>
<!-- User info card -->
<view class="user-card fade-in-up">
<view class="user-card__avatar">
<text class="user-card__avatar-text">
{{ (userInfo?.name || '管').charAt(0) }}
</text>
</view>
<view class="user-card__info">
<text class="user-card__name">
{{ userInfo?.name || 'FastapiAdmin' }}
</text>
<text class="user-card__role">
{{ userInfo?.roles?.map(r => r.name).join(', ') || '超级管理员' }}
</text>
</view>
</view>
<!-- Module groups -->
<view v-for="(group, gi) in groups" :key="gi" class="module-group" :class="[`fade-in-up-${gi + 1}`]">
<text class="module-group__title">
{{ group.title }}
</text>
<view class="module-grid">
<view
v-for="item in group.items"
:key="item.name"
class="module-item"
hover-class="module-item--hover"
@click="navigateTo(item.name)"
>
<view class="module-item__icon" :style="{ background: group.bg }">
<wd-icon :name="item.icon" size="22px" :color="group.color" />
</view>
<text class="module-item__text">
{{ item.text }}
</text>
</view>
</view>
</view>
<!-- Bottom safe area -->
<view style="height: 100rpx;" />
</view>
</template>
<style lang="scss" scoped>
.work-page {
padding: 0 32rpx;
padding-bottom: calc(120rpx + env(safe-area-inset-bottom));
background: var(--page-bg-color, #F9F9F9);
min-height: 100vh;
}
/* ===== Navigation ===== */
.work-nav {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16rpx 0 24rpx;
&__title {
font-size: var(--font-2xl, 40rpx);
font-weight: 600;
color: var(--text-color, #0A1628);
}
&__avatar {
width: 72rpx;
height: 72rpx;
border-radius: 50%;
background: linear-gradient(135deg, var(--primary-color, #4F8CFF), var(--primary-color-dark, #2970FF));
display: flex;
align-items: center;
justify-content: center;
}
&__avatar-text {
font-size: var(--font-md, 28rpx);
font-weight: 600;
color: #FFFFFF;
}
}
/* ===== User card ===== */
.user-card {
display: flex;
align-items: center;
gap: 24rpx;
background: var(--card-bg-color, #FFFFFF);
border-radius: 32rpx;
padding: 32rpx;
margin-bottom: 40rpx;
box-shadow: var(--shadow-sm, 0 1rpx 2rpx rgba(1, 77, 178,0.06));
&__avatar {
width: 96rpx;
height: 96rpx;
border-radius: 50%;
background: linear-gradient(135deg, var(--primary-color, #4F8CFF), var(--primary-color-dark, #2970FF));
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
&__avatar-text {
font-size: var(--font-xl, 36rpx);
font-weight: 600;
color: #FFFFFF;
}
&__info {
display: flex;
flex-direction: column;
gap: 4rpx;
}
&__name {
font-size: var(--font-xl, 36rpx);
font-weight: 600;
color: var(--text-color, #0A1628);
}
&__role {
font-size: var(--font-sm, 24rpx);
color: var(--text-color-3, #6B7280);
}
}
/* ===== Module group ===== */
.module-group {
margin-bottom: 40rpx;
&__title {
display: block;
font-size: var(--font-lg, 32rpx);
font-weight: 600;
color: var(--text-color, #0A1628);
margin-bottom: 20rpx;
}
}
.module-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 16rpx;
}
.module-item {
display: flex;
align-items: center;
gap: 20rpx;
background: var(--card-bg-color, #FFFFFF);
border-radius: 24rpx;
padding: 24rpx;
box-shadow: var(--shadow-sm, 0 1rpx 2rpx rgba(1, 77, 178,0.06));
transition: all 0.15s ease;
&--hover {
opacity: 0.85;
transform: scale(0.98);
box-shadow: var(--shadow-md, 0 4rpx 12rpx rgba(1, 77, 178,0.10));
}
&__icon {
width: 64rpx;
height: 64rpx;
border-radius: 16rpx;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
&__text {
font-size: var(--font-md, 28rpx);
font-weight: 500;
color: var(--text-color, #0A1628);
}
}
</style>