feat(core): 初始化项目基础架构和CMS功能模块

- 添加Docker相关配置文件(.dockerignore, .env.example, .gitignore)
- 实现服务端API代理功能,支持文件、模块和服务器API转发
- 创建文章详情页、栏目文章列表页和单页内容展示页面
- 集成Ant Design Vue组件库并实现SSR样式提取功能
- 定义API响应数据结构类型和应用布局组件
- 开发开发者应用中心和文章管理页面
- 实现CMS导航菜单获取和多租户切换功能
This commit is contained in:
2026-01-27 00:14:08 +08:00
commit 775841eed3
315 changed files with 47072 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>