feat(app): 初始化项目配置和页面结构

- 添加 .dockerignore 和 .env.example 配置文件
- 添加 .gitignore 忽略规则配置
- 创建服务端代理API路由(_file、_modules、_server)
- 集成 Ant Design Vue 组件库并配置SSR样式提取
- 定义API响应类型封装
- 创建基础布局组件(blank、console)
- 实现应用中心页面和组件(AppsCenter)
- 添加文章列表测试页面
- 配置控制台导航菜单结构
- 实现控制台头部组件
- 创建联系页面表单
This commit is contained in:
2026-01-17 18:23:37 +08:00
commit 5e26fdc7fb
439 changed files with 56219 additions and 0 deletions

252
app/pages/qr-confirm.vue Normal file
View File

@@ -0,0 +1,252 @@
<template>
<div class="page">
<a-card :bordered="false" class="card">
<div class="header">
<div class="app">
<img :src="appLogo" class="logo" alt="logo" />
<h3 class="name">{{ appName }}</h3>
</div>
<p class="tip">确认登录到 Web </p>
</div>
<div v-if="userInfo" class="user">
<a-avatar :size="64" :src="userInfo.avatar">
<template v-if="!userInfo.avatar" #icon><UserOutlined /></template>
</a-avatar>
<div class="user-text">
<h4 class="username">{{ userInfo.nickname || userInfo.username }}</h4>
<p class="phone">{{ userInfo.phone || userInfo.mobile }}</p>
</div>
</div>
<div class="device">
<div class="row"><span class="label">登录设备</span><span class="value">{{ deviceInfo.browser }} {{ deviceInfo.version }}</span></div>
<div class="row"><span class="label">操作系统</span><span class="value">{{ deviceInfo.os }}</span></div>
<div class="row"><span class="label">IP 地址</span><span class="value">{{ deviceInfo.ip }}</span></div>
<div class="row"><span class="label">登录时间</span><span class="value">{{ formatTime(new Date()) }}</span></div>
</div>
<div class="actions">
<a-button size="large" class="cancel" :loading="cancelLoading" @click="handleCancel">取消登录</a-button>
<a-button type="primary" size="large" class="confirm" :loading="confirmLoading" @click="handleConfirm">确认登录</a-button>
</div>
<div class="security">
<ExclamationCircleOutlined class="warn" />
<span>请确认是您本人操作如非本人操作请点击取消登录</span>
</div>
</a-card>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { message } from 'ant-design-vue'
import { ExclamationCircleOutlined, UserOutlined } from '@ant-design/icons-vue'
import { confirmQrLogin, scanQrCode, type QrLoginConfirmRequest } from '@/api/passport/qrLogin'
import { getUserInfo } from '@/api/layout'
import { getToken } from '@/utils/token-util'
import type { User } from '@/api/system/user/model'
definePageMeta({ layout: 'blank' })
const route = useRoute()
const qrCodeKey = computed(() => String(route.query.qrCodeKey || ''))
const userInfo = ref<User | null>(null)
const confirmLoading = ref(false)
const cancelLoading = ref(false)
const appName = ref('网宿软件')
const appLogo = ref('/favicon.ico')
const deviceInfo = ref({
browser: 'Mobile',
version: '',
os: 'Unknown',
ip: '-'
})
function formatTime(date: Date) {
return date.toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
})
}
async function fetchUser() {
const token = getToken()
if (!token) {
message.error('请先登录')
await navigateTo('/login')
return false
}
try {
userInfo.value = await getUserInfo()
return true
} catch (error: unknown) {
console.error(error)
message.error('获取用户信息失败')
return false
}
}
async function markScanned() {
if (!qrCodeKey.value) return
try {
await scanQrCode(qrCodeKey.value)
} catch {
// ignore
}
}
async function handleConfirm() {
if (!qrCodeKey.value) return message.error('二维码参数错误')
confirmLoading.value = true
try {
if (!userInfo.value?.userId) return message.error('用户信息获取失败')
const requestData: QrLoginConfirmRequest = {
token: qrCodeKey.value,
userId: Number(userInfo.value.userId),
platform: 'web'
}
await confirmQrLogin(requestData)
message.success('登录确认成功')
setTimeout(() => backOrHome(), 1200)
} catch (e: unknown) {
message.error(e instanceof Error ? e.message : '确认登录失败')
} finally {
confirmLoading.value = false
}
}
function backOrHome() {
if (import.meta.client && window.history.length > 1) {
window.history.back()
return
}
navigateTo('/')
}
function handleCancel() {
cancelLoading.value = true
try {
message.info('已取消登录')
setTimeout(() => backOrHome(), 800)
} finally {
cancelLoading.value = false
}
}
onMounted(async () => {
if (!qrCodeKey.value) {
message.error('二维码参数错误')
await navigateTo('/login')
return
}
const ok = await fetchUser()
if (!ok) return
await markScanned()
})
</script>
<style scoped>
.page {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 24px 16px;
background: #f5f5f5;
}
.card {
width: 480px;
max-width: 100%;
border-radius: 12px;
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.08);
}
.header {
text-align: center;
padding: 8px 0 12px;
}
.app {
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
}
.logo {
width: 40px;
height: 40px;
border-radius: 10px;
}
.name {
margin: 0;
font-weight: 700;
color: #111827;
}
.tip {
margin: 10px 0 0;
color: #6b7280;
}
.user {
display: flex;
align-items: center;
gap: 14px;
padding: 14px 4px;
}
.user-text {
flex: 1;
}
.username {
margin: 0;
font-size: 16px;
font-weight: 700;
}
.phone {
margin: 4px 0 0;
color: #6b7280;
}
.device {
padding: 12px 4px 4px;
border-top: 1px solid #f0f0f0;
border-bottom: 1px solid #f0f0f0;
}
.row {
display: flex;
justify-content: space-between;
padding: 6px 0;
gap: 12px;
}
.label {
color: #6b7280;
}
.value {
color: #111827;
font-weight: 500;
}
.actions {
display: flex;
gap: 12px;
padding: 16px 4px 6px;
}
.cancel,
.confirm {
flex: 1;
}
.security {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 4px 2px;
color: #6b7280;
font-size: 12px;
}
.warn {
color: #faad14;
}
</style>