蓝红票功能

This commit is contained in:
BBIT-Kai
2026-05-19 15:12:58 +08:00
parent 2401b6e512
commit 74fdddd113
113 changed files with 41526 additions and 2444 deletions
+6 -51
View File
@@ -1,10 +1,10 @@
<template>
<template>
<n-config-provider :theme-overrides="themeOverrides" :locale="zhCN" :date-locale="dateZhCN">
<n-loading-bar-provider>
<n-dialog-provider>
<n-notification-provider>
<n-message-provider>
<router-view />
<n-message-provider :keep-alive-on-hover="true" :max="6" placement="top">
<AppMessageBridge />
</n-message-provider>
</n-notification-provider>
</n-dialog-provider>
@@ -13,52 +13,7 @@
</template>
<script setup lang="ts">
import { dateZhCN, zhCN, type GlobalThemeOverrides } from 'naive-ui'
const themeOverrides: GlobalThemeOverrides = {
common: {
primaryColor: '#2563eb',
primaryColorHover: '#1d4ed8',
primaryColorPressed: '#1e40af',
primaryColorSuppl: '#14b8a6',
borderRadius: '8px',
borderRadiusSmall: '6px',
fontFamily: 'Inter, "Avenir Next", "PingFang SC", "Microsoft YaHei", sans-serif'
},
Card: {
borderRadius: '8px'
},
Button: {
borderRadiusMedium: '8px'
},
Input: {
borderRadius: '8px'
},
Select: {
peers: {
InternalSelection: {
borderRadius: '8px'
}
}
},
Menu: {
itemHeight: '40px',
itemBorderRadius: '8px',
itemTextColor: '#64748b',
itemTextColorHover: '#0f172a',
itemTextColorActive: '#111827',
itemTextColorActiveHover: '#111827',
itemIconColor: '#94a3b8',
itemIconColorHover: '#2563eb',
itemIconColorActive: '#2563eb',
itemColorActive: '#eff6ff',
itemColorActiveHover: '#eff6ff'
},
DataTable: {
thColor: '#f8fafc',
tdColor: '#ffffff',
borderColor: '#e2e8f0',
thTextColor: '#475569'
}
}
import { dateZhCN, zhCN } from 'naive-ui'
import AppMessageBridge from '@/components/AppMessageBridge.vue'
import { themeOverrides } from '@/config/theme'
</script>
+42 -26
View File
@@ -1,12 +1,17 @@
import axios, { AxiosError } from 'axios'
import { createDiscreteApi } from 'naive-ui'
import { appEnv } from '@/config/env'
import type { ApiResult } from '@/types/http'
import { BizError } from '@/types/http'
import { redirectToLogin } from '@/utils/auth-session'
import { appMessage } from '@/utils/message'
import { useAuthStore } from '@/stores/auth'
import { router } from '@/router'
import { appEnv } from '@/config/env'
const { message } = createDiscreteApi(['message'])
const AUTH_ERROR_CODES = new Set([
'AUTH.UNAUTHORIZED',
'AUTH.TOKEN_VERSION_INVALID',
'AUTH.USER_DISABLED',
'AUTH.USERNAME_OR_PASSWORD_INVALID'
])
const http = axios.create({
baseURL: appEnv.apiBaseUrl,
@@ -23,21 +28,33 @@ http.interceptors.request.use((config) => {
})
http.interceptors.response.use(
(response) => {
async (response) => {
const payload = response.data as ApiResult<unknown>
const traceId = response.headers['x-trace-id'] as string | undefined
if (!payload || typeof payload.code !== 'string') {
return response.data
}
if (payload.code !== '0') {
const errMsg = payload.message || '请求失败'
message.error(payload.traceId ?? traceId ? `${errMsg}(追踪ID${payload.traceId ?? traceId}` : errMsg)
throw new BizError(payload.code, errMsg, payload.traceId ?? traceId)
if (payload.code === '0') {
return payload.data
}
return payload.data
const errorMessage = payload.message || '请求失败'
const resolvedTraceId = payload.traceId ?? traceId
if (AUTH_ERROR_CODES.has(payload.code)) {
await redirectToLogin('expired')
appMessage.warning(errorMessage)
throw new BizError(payload.code, errorMessage, resolvedTraceId)
}
appMessage.error(
resolvedTraceId ? `${errorMessage}(追踪ID${resolvedTraceId}` : errorMessage
)
throw new BizError(payload.code, errorMessage, resolvedTraceId)
},
async (error: AxiosError<ApiResult<unknown>>) => {
const authStore = useAuthStore()
const status = error.response?.status
const traceId =
(error.response?.headers?.['x-trace-id'] as string | undefined) ??
@@ -45,26 +62,25 @@ http.interceptors.response.use(
const backendMessage = error.response?.data?.message
if (status === 401) {
authStore.clearAuth()
if (router.currentRoute.value.path !== '/login') {
await router.replace({
path: '/login',
query: { redirect: router.currentRoute.value.fullPath }
})
}
message.warning(backendMessage ?? '登录已失效,请重新登录')
return Promise.reject(new BizError('401', backendMessage ?? '未登录', traceId))
const errorMessage = backendMessage ?? '未登录或登录已失效,请重新登录'
await redirectToLogin('expired')
appMessage.warning(errorMessage)
return Promise.reject(new BizError('401', errorMessage, traceId))
}
if (status === 403) {
console.warn('[HTTP] 收到 403 响应, URL:', error.config?.url, 'Message:', backendMessage)
message.error(backendMessage ?? '无权限访问该资源')
return Promise.reject(new BizError('403', backendMessage ?? '无权限', traceId))
const errorMessage = backendMessage ?? '无权限访问该资源'
console.warn('[HTTP] 收到 403 响应', {
url: error.config?.url,
message: backendMessage
})
appMessage.error(errorMessage)
return Promise.reject(new BizError('403', errorMessage, traceId))
}
const messageText = backendMessage ?? error.message ?? '网络异常,请稍后重试'
message.error(traceId ? `${messageText}(追踪ID${traceId}` : messageText)
return Promise.reject(new BizError(String(status ?? 'HTTP_ERROR'), messageText, traceId))
const errorMessage = backendMessage ?? error.message ?? '网络异常,请稍后重试'
appMessage.error(traceId ? `${errorMessage}(追踪ID${traceId}` : errorMessage)
return Promise.reject(new BizError(String(status ?? 'HTTP_ERROR'), errorMessage, traceId))
}
)
+335 -16
View File
@@ -175,6 +175,8 @@ export function updateDigitalAccountApi(payload: DigitalAccountInfo): Promise<st
export interface PresetData {
bankName: string
bankAccount: string
address: string
phone: string
}
/** 获取开票预设数据 */
@@ -223,6 +225,14 @@ export interface InvoiceItem {
zeroTaxFlag?: string
/** 增值税特殊管理说明 */
vatSpecialManage?: string
/** 行号 */
lineNo?: number
/** 成品油站点 */
station?: string
/** 交易时间 yyyy-MM-dd HH:mm:ss */
tradeTime?: string
/** 加油枪号码 */
gunNumber?: string
}
/** 差额征税凭证明细 */
@@ -273,6 +283,12 @@ export interface InvoiceRequest {
buyerTaxpayerNum?: string
/** 是否自然人:0否,1是 */
naturalPersonFlag?: string
/** 自然人证件类型:201居民身份证, 208外国护照, 210港澳居民来往内地通行证, 213台湾居民来往大陆通行证, 227中国护照, 233外国人永久居留身份证, 237中华人民共和国港澳居民居住证, 238中华人民共和国台湾居民居住证 */
naturalPersonIdType?: string
/** 自然人证件号码 */
naturalPersonIdNo?: string
/** 自然人国籍/地区代码:156中华人民共和国, 158中国台湾, 344中国香港特别行政区, 446中国澳门特别行政区 */
naturalPersonCountry?: string
/** 购买方地址 */
buyerAddress?: string
/** 购买方电话 */
@@ -336,6 +352,30 @@ export function invoiceIssueApi(payload: InvoiceRequest): Promise<string> {
return http.post('/pt/invoiceBlue', payload)
}
// =============================================
// 红票创建
// =============================================
export interface RedCreateRequest {
/** 蓝票历史记录 ID */
historyId: string
/** 冲红原因:01开票有误 02销货退回 03服务中止 04销售折让 */
redReason: string
/** 收票人名称 */
takerName?: string
/** 收票人手机号 */
takerTel?: string
/** 收票人邮箱 */
takerEmail?: string
}
/**
* 创建红票任务
*/
export function redInvoiceCreateApi(payload: RedCreateRequest): Promise<string> {
return http.post('/pt/invoiceRed', payload)
}
// =============================================
// 开票历史
// =============================================
@@ -355,12 +395,24 @@ export interface InvoiceHistoryItem {
invoiceReqSerialNo: string
/** 销方税号 */
taxpayerNum: string
/** 发票种类 */
/** 发票种类编码 */
invoiceKindCode: string
/** 发票类型:BLUE 蓝票, RED 红票 */
invoiceType: string
// ===== 购买方信息 =====
/** 购买方名称 */
buyerName: string
/** 购买方税号 */
buyerTaxpayerNum?: string
/** 是否自然人:0否 1是 */
naturalPersonFlag?: string
/** 自然人证件类型 */
naturalPersonIdType?: string
/** 自然人证件号码 */
naturalPersonIdNo?: string
/** 自然人国籍/地区代码 */
naturalPersonCountry?: string
/** 购买方地址 */
buyerAddress?: string
/** 购买方电话 */
@@ -369,12 +421,28 @@ export interface InvoiceHistoryItem {
buyerBankName?: string
/** 购买方银行账号 */
buyerBankAccount?: string
// ===== 销售方信息 =====
/** 销售方名称 */
sellerName?: string
/** 销售方地址 */
sellerAddress?: string
/** 销售方电话 */
sellerTel?: string
/** 销售方开户银行 */
sellerBankName?: string
/** 销售方银行账号 */
sellerBankAccount?: string
// ===== 金额 =====
/** 不含税金额 */
amount: string
/** 税额 */
taxAmount: string
/** 含税总金额 */
totalAmount: string
// ===== 开票结果 =====
/** 发票号码 */
invoiceNo?: string
/** 发票代码 */
@@ -385,22 +453,76 @@ export interface InvoiceHistoryItem {
issuedAt?: string
/** 开票状态 */
status: string
/** 状态码 */
code?: string
/** 状态描述/错误信息 */
msg?: string
/** 实名认证ID */
authId?: string
// ===== 特殊票种 =====
/** 特殊票种标识 */
specialInvoiceKind?: string
// ===== 红冲关联 =====
/** 原蓝票发票代码 */
blueInvoiceCode?: string
/** 原蓝票发票号码 */
blueInvoiceNo?: string
/** 原数电发票号码 */
blueAllEleInvNo?: string
// ===== 发票状态标记 =====
/** 作废状态 */
invalidFlag?: string
/** 冲红状态 */
redFlag?: string
// ===== 人员信息 =====
/** 开票人 */
drawerName?: string
/** 收款人 */
casherName?: string
/** 复核人 */
reviewerName?: string
// ===== 数电信息 =====
/** 数电账号 */
account?: string
/** 数电开票通道 */
invIssueChannel?: string
// ===== 清单 =====
/** 是否清单票:0否 1是 */
detailedListFlag?: string
/** 清单项目名称 */
detailedListItemName?: string
// ===== 文件 =====
/** PDF 地址 */
pdfUrl?: string
/** OFD 地址 */
ofdUrl?: string
/** XML 地址 */
xmlUrl?: string
/** 发票版式文件类型:pdf/ofd */
invoiceLayoutFileType?: string
// ===== 业务字段 =====
/** 订单号 */
tradeNo?: string
/** 备注 */
remark?: string
/** 自定义透传数据 */
definedData?: string
// ===== 审计字段 =====
/** 创建时间 */
createdAt: string
/** 错误信息 */
errorMessage?: string
/** 最后更新时间 */
updatedAt?: string
/** 删除标记 */
invDeletedFlag?: string
}
/** 发票种类映射 */
@@ -416,25 +538,29 @@ export const invoiceKindMap: Record<string, string> = {
/** 开票状态映射 */
export const invoiceStatusMap: Record<string, string> = {
'PENDING': '待处理',
'PROCESSING': '处理中',
'SUCCESS': '开票成功',
'FAILED': '开票失败'
PENDING: '待处理',
PROCESSING: '处理中',
SUCCESS: '开票成功',
FAILED: '开票失败'
}
/** 开票状态颜色映射 */
export const invoiceStatusColorMap: Record<string, string> = {
'PENDING': '#faad14',
'PROCESSING': '#409eff',
'SUCCESS': '#52c41a',
'FAILED': '#f56c6c'
PENDING: '#faad14',
PROCESSING: '#409eff',
SUCCESS: '#52c41a',
FAILED: '#f56c6c'
}
/**
* 分页查询蓝票开票历史
* 分页查询开票历史(支持按类型和状态筛选)
*/
export function invoiceHistoryApi(page: number, pageSize: number): Promise<PageResult<InvoiceHistoryItem>> {
return http.get('/pt/invoiceBlueHistory', { params: { page, pageSize } })
export function invoiceHistoryApi(
page: number,
pageSize: number,
params?: { invoiceType?: string; isSuccess?: boolean }
): Promise<PageResult<InvoiceHistoryItem>> {
return http.get('/pt/invoiceBlueHistory', { params: { page, pageSize, ...params } })
}
// =============================================
@@ -454,6 +580,16 @@ export interface InvoiceDetailGoods {
taxRateValue: string
taxRateAmount?: string
includeTaxFlag: boolean
/** 折扣金额 */
discountAmount?: string
/** 零税率标识:1=免税,2=不征税,3=普通零税率 */
zeroTaxFlag?: string
/** 优惠政策标识:1=使用优惠政策 */
preferentialPolicyFlag?: string
/** 增值税特殊管理 */
vatSpecialManage?: string
/** 差额征税扣除金额 */
deductionAmount?: string
}
/** 差额征税凭证明细(详情) */
@@ -482,28 +618,78 @@ export interface InvoiceDetailResponse {
taxpayerNum: string
invoiceKindCode: string
invoiceType: string
// ===== 购买方信息 =====
buyerName: string
buyerTaxpayerNum?: string
naturalPersonFlag?: string
naturalPersonIdType?: string
naturalPersonIdNo?: string
naturalPersonCountry?: string
buyerAddress?: string
buyerTel?: string
buyerBankName?: string
buyerBankAccount?: string
// ===== 销售方信息 =====
sellerName?: string
sellerAddress?: string
sellerTel?: string
sellerBankName?: string
sellerBankAccount?: string
// ===== 金额 =====
amount: string
taxAmount: string
totalAmount: string
// ===== 开票结果 =====
invoiceNo?: string
invoiceCode?: string
electronicInvoiceNo?: string
issuedAt?: string
status: string
errorMessage?: string
code?: string
msg?: string
authId?: string
specialInvoiceKind?: string
// ===== 红冲关联 =====
blueInvoiceCode?: string
blueInvoiceNo?: string
blueAllEleInvNo?: string
// ===== 发票状态标记 =====
invalidFlag?: string
redFlag?: string
// ===== 人员信息 =====
drawerName?: string
casherName?: string
reviewerName?: string
// ===== 数电信息 =====
account?: string
invIssueChannel?: string
// ===== 清单 =====
detailedListFlag?: string
detailedListItemName?: string
// ===== 文件 =====
pdfUrl?: string
ofdUrl?: string
xmlUrl?: string
invoiceLayoutFileType?: string
// ===== 业务 =====
tradeNo?: string
remark?: string
definedData?: string
createdAt: string
updatedAt?: string
invDeletedFlag?: string
/** 商品明细 */
goodsList: InvoiceDetailGoods[]
/** 差额征税凭证明细 */
@@ -522,6 +708,139 @@ export function invoiceDetailApi(invoiceReqSerialNo: string): Promise<InvoiceDet
/**
* 查询并刷新发票状态
*/
export function queryInvoiceApi(invoiceReqSerialNo: string): Promise<{ invoiceReqSerialNo: string; status: string }> {
export function queryInvoiceApi(
invoiceReqSerialNo: string
): Promise<{ invoiceReqSerialNo: string; status: string }> {
return http.get('/pt/queryInvoice', { params: { invoiceReqSerialNo } })
}
// =============================================
// 红票申请信息
// =============================================
/** 红票申请信息 */
export interface RedInvoiceInfo {
/** 冲红原因:01开票有误 02销货退回 03服务中止 04销售折让 */
redReason: string
/** 收票人名称 */
takerName?: string
/** 收票人电话 */
takerTel?: string
/** 收票人邮箱 */
takerEmail?: string
}
/** 红票原因映射 */
export const redReasonMap: Record<string, string> = {
'01': '开票有误',
'02': '销货退回',
'03': '服务中止',
'04': '销售折让'
}
/**
* 查询红票申请信息
*/
export function redInvoiceInfoApi(invoiceReqSerialNo: string): Promise<RedInvoiceInfo> {
return http.get('/pt/invoiceRedInfo', { params: { invoiceReqSerialNo } })
}
// =============================================
// 登录认证
// =============================================
/** 发送登录短信验证码请求 */
export interface SendLoginSmsCodeRequest {
taxpayerNum: string
account: string
}
/** 发送登录短信验证码响应 */
export interface LoginSmsCodeResponse {
taxpayerNum?: string
account?: string
resultCode: string
resultMsg: string
phoneNum?: string
activeTime?: string
}
/**
* 发送登录短信验证码
*/
export function sendLoginSmsCodeApi(
payload: SendLoginSmsCodeRequest
): Promise<LoginSmsCodeResponse> {
return http.post('/pt/send-sms-code', payload)
}
/** 短信验证码登录请求 */
export interface SmsLoginRequest {
taxpayerNum: string
account: string
smsCode: string
}
/** 短信验证码登录响应 */
export interface SmsLoginResponse {
taxpayerNum: string
account: string
resultCode: string
resultMsg: string
}
/**
* 短信验证码登录
*/
export function smsLoginApi(payload: SmsLoginRequest): Promise<SmsLoginResponse> {
return http.post('/pt/sms-login', payload)
}
// =============================================
// 实名认证二维码
// =============================================
/** 获取实名认证二维码响应 */
export interface AuthQrcodeResponse {
taxpayerNum: string
account: string
resultCode: string
resultMsg: string
/** base64 二维码图片数据 */
qrcodeImg?: string
/** 二维码生成时间 yyyy-MM-dd HH:mm:ss */
genTime?: string
/** 认证 ID,用于查询扫码状态 */
authId?: string
/** 二维码类型:1 电子税务局 APP, 2 国家网络身份认证 APP */
qrcodeType?: string
/** 认证类型:0 登录认证, 1 风险认证 */
authType?: string
}
/**
* 获取实名认证二维码
* @param qrcodeType 1: 电子税务局 APP, 2: 国家网络身份认证 APP
*/
export function getAuthQrcodeApi(qrcodeType: string): Promise<AuthQrcodeResponse> {
return http.get('/pt/authentication', { params: { qrcodeType } })
}
/** 查询认证二维码扫码状态响应 */
export interface AuthQrcodeStatusResponse {
/** 扫码状态: 0 未扫码, 1 已扫码待确认, 2 认证成功, 3 已过期 */
scanStatus?: string
/** 状态描述信息 */
msg?: string
}
/**
* 查询认证二维码扫码状态
*/
export function queryAuthStatusApi(payload: {
taxpayerNum: string
account: string
authId: string
}): Promise<AuthQrcodeStatusResponse> {
return http.post('/pt/query-auth-status', payload)
}
+9 -2
View File
@@ -1,5 +1,5 @@
<template>
<n-breadcrumb>
<template>
<n-breadcrumb class="app-breadcrumb">
<n-breadcrumb-item v-for="item in crumbs" :key="item.path">
{{ item.title }}
</n-breadcrumb-item>
@@ -19,5 +19,12 @@ const crumbs = computed(() =>
path: item.path,
title: item.meta.title as string
}))
.slice(0, -1)
)
</script>
<style scoped>
.app-breadcrumb {
padding: 4px 0;
}
</style>
+56 -15
View File
@@ -1,11 +1,11 @@
<template>
<template>
<n-menu
:collapsed="authStore.layoutCollapsed"
:collapsed-width="72"
:collapsed-width="84"
:collapsed-icon-size="18"
:options="menuOptions"
:value="activePath"
:indent="18"
:indent="16"
@update:value="handleSelect"
/>
</template>
@@ -30,14 +30,13 @@ function renderIcon(icon?: string | null) {
}
function toOption(menu: MenuNode): MenuOption | null {
if (!menu.visible) return null
if (menu.type === 'BUTTON') return null
if (!menu.visible || menu.type === 'BUTTON') return null
const children = (menu.children ?? [])
.map((item) => toOption(item))
.filter((item): item is MenuOption => Boolean(item))
const key = (menu.path && menu.type !== 'CATALOG') ? menu.path : `catalog-${menu.id}`
const key = menu.path && menu.type !== 'CATALOG' ? menu.path : `catalog-${menu.id}`
return {
key,
label: menu.title,
@@ -60,19 +59,40 @@ function handleSelect(key: string) {
<style scoped>
:deep(.n-menu) {
height: 100%;
overflow-x: hidden;
overflow-y: auto;
font-size: 13px;
background: transparent;
scrollbar-width: none;
-ms-overflow-style: none;
}
:deep(.n-menu-item) {
:deep(.n-menu::-webkit-scrollbar) {
display: none;
}
:deep(.n-menu-content),
:deep(.n-menu-content-wrapper) {
scrollbar-width: none;
-ms-overflow-style: none;
}
:deep(.n-menu-content::-webkit-scrollbar),
:deep(.n-menu-content-wrapper::-webkit-scrollbar) {
display: none;
}
:deep(.n-menu-item),
:deep(.n-submenu) {
margin: 2px 0;
}
:deep(.n-menu-item-content) {
color: #64748b;
:deep(.n-menu-item-content),
:deep(.n-submenu .n-menu-item-content-header) {
transition:
background 0.18s ease,
color 0.18s ease;
background 0.16s ease,
color 0.16s ease;
}
:deep(.n-menu-item-content-header),
@@ -86,11 +106,32 @@ function handleSelect(key: string) {
white-space: nowrap;
}
:deep(.n-menu-item-content--selected::before) {
background: #eff6ff;
}
:deep(.n-menu-item-content--selected) {
font-weight: 600;
box-shadow: none;
}
:deep(.n-menu-item-content--selected::before) {
background: transparent;
}
:deep(.n-menu--collapsed .n-menu-item-content),
:deep(.n-menu--collapsed .n-submenu .n-menu-item-content-header) {
justify-content: center;
padding-left: 0 !important;
padding-right: 0 !important;
}
:deep(.n-menu--collapsed .n-menu-item-content__icon),
:deep(.n-menu--collapsed .n-submenu .n-menu-item-content-header__icon) {
margin-right: 0 !important;
width: 100%;
display: flex;
justify-content: center;
}
:deep(.n-menu-item-content__icon .n-icon),
:deep(.n-menu-item-content-header__icon .n-icon) {
font-size: 18px;
}
</style>
+10
View File
@@ -0,0 +1,10 @@
<template>
<router-view />
</template>
<script setup lang="ts">
import { useMessage } from 'naive-ui'
import { setMessageApi } from '@/utils/message'
setMessageApi(useMessage())
</script>
+21 -23
View File
@@ -1,4 +1,4 @@
<template>
<template>
<div class="tabs-shell">
<div class="tabs-track">
<button
@@ -39,54 +39,53 @@ function onClose(path: string) {
<style scoped>
.tabs-shell {
display: flex;
align-items: center;
min-height: 36px;
margin-top: 8px;
border-top: 1px solid #eef2f7;
padding-top: 7px;
margin-top: 12px;
padding-top: 10px;
border-top: 1px solid #eef1f5;
}
.tabs-track {
flex: 1;
min-width: 0;
display: flex;
align-items: center;
gap: 4px;
gap: 8px;
overflow-x: auto;
padding-bottom: 2px;
scrollbar-width: none;
}
.tabs-track::-webkit-scrollbar {
display: none;
}
.tab-chip {
display: inline-flex;
align-items: center;
gap: 6px;
height: 32px;
padding: 0 12px;
border: 1px solid transparent;
border-radius: 8px;
border-radius: 9px;
background: transparent;
color: #64748b;
height: 28px;
padding: 0 10px;
color: #6b7280;
cursor: pointer;
transition: all 0.18s ease;
transition:
background 0.16s ease,
color 0.16s ease;
white-space: nowrap;
}
.tab-chip:hover {
background: #f1f5f9;
color: #0f172a;
background: #f3f4f6;
color: #111827;
}
.tab-chip-active {
background: #2563eb;
border-color: #2563eb;
background: #111827;
color: #ffffff;
box-shadow: 0 8px 16px rgba(37, 99, 235, 0.16);
}
.tab-title {
font-size: 13px;
line-height: 1;
}
.tab-close {
@@ -96,12 +95,11 @@ function onClose(path: string) {
width: 16px;
height: 16px;
border-radius: 999px;
color: currentColor;
opacity: 0.72;
}
.tab-close:hover {
background: rgba(255, 255, 255, 0.18);
background: rgba(255, 255, 255, 0.16);
opacity: 1;
}
</style>
+87
View File
@@ -0,0 +1,87 @@
import type { GlobalThemeOverrides } from 'naive-ui'
export const themeOverrides: GlobalThemeOverrides = {
common: {
primaryColor: '#111827',
primaryColorHover: '#1f2937',
primaryColorPressed: '#030712',
primaryColorSuppl: '#111827',
infoColor: '#475569',
successColor: '#166534',
warningColor: '#b45309',
errorColor: '#b91c1c',
borderRadius: '10px',
borderRadiusSmall: '8px',
fontFamily: '"PingFang SC", "Microsoft YaHei", "Helvetica Neue", Arial, sans-serif',
fontWeightStrong: '600'
},
Layout: {
color: 'transparent',
siderColor: 'transparent'
},
Card: {
color: '#ffffff',
borderRadius: '14px',
borderColor: '#e5e7eb',
boxShadow: '0 1px 2px rgba(15, 23, 42, 0.04)'
},
Button: {
borderRadiusMedium: '10px',
borderRadiusSmall: '8px',
fontWeight: '500'
},
Input: {
borderRadius: '10px',
color: '#ffffff'
},
Select: {
peers: {
InternalSelection: {
borderRadius: '10px',
color: '#ffffff'
}
}
},
Tabs: {
tabBorderRadius: '10px'
},
Tag: {
borderRadius: '999px'
},
Menu: {
itemHeight: '42px',
itemBorderRadius: '10px',
itemTextColor: '#6b7280',
itemTextColorHover: '#111827',
itemTextColorActive: '#111827',
itemTextColorActiveHover: '#111827',
itemIconColor: '#9ca3af',
itemIconColorHover: '#111827',
itemIconColorActive: '#111827',
itemColorHover: '#f3f4f6',
itemColorActive: '#f3f4f6',
itemColorActiveHover: '#f3f4f6',
arrowColor: '#9ca3af',
arrowColorHover: '#111827',
arrowColorActive: '#111827',
groupTextColor: '#9ca3af'
},
DataTable: {
thColor: '#fafafa',
tdColor: '#ffffff',
borderColor: '#eceff3',
thTextColor: '#6b7280',
tdTextColor: '#111827'
},
Breadcrumb: {
itemTextColor: '#9ca3af',
itemTextColorHover: '#111827',
itemTextColorPressed: '#111827',
itemColorHover: 'transparent',
itemColorPressed: 'transparent'
},
Modal: {
borderRadius: '16px',
color: '#ffffff'
}
}
+23 -7
View File
@@ -1,13 +1,21 @@
<template>
<template>
<div class="page-shell">
<div class="grid grid-cols-1 gap-4 md:grid-cols-3 lg:grid-cols-4">
<div class="soft-stat"><n-statistic label="可见菜单" :value="visibleMenuTotal" /></div>
<div class="soft-stat"><n-statistic label="可访问页面" :value="visiblePageTotal" /></div>
<div class="dashboard-grid">
<div class="soft-stat">
<n-statistic label="权限码数量" :value="authStore.permissions.length" />
<n-statistic label="可见菜单" :value="visibleMenuTotal" />
</div>
<div class="soft-stat">
<n-statistic label="可访问页面" :value="visiblePageTotal" />
</div>
<div class="soft-stat">
<n-statistic label="权限数量" :value="authStore.permissions.length" />
</div>
<div class="soft-stat">
<n-statistic label="当前状态" :value="currentStatus" />
</div>
<div class="soft-stat">
<n-statistic label="已打开页签" :value="authStore.tabs.length" />
</div>
<div class="soft-stat"><n-statistic label="当前状态" :value="currentStatus" /></div>
<div class="soft-stat"><n-statistic label="已打开页签" :value="authStore.tabs.length" /></div>
</div>
</div>
</template>
@@ -31,3 +39,11 @@ const visiblePageTotal = computed(
)
const currentStatus = computed(() => statusLabel(authStore.user?.status))
</script>
<style scoped>
.dashboard-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 12px;
}
</style>
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+245 -144
View File
@@ -1,27 +1,31 @@
<template>
<n-layout has-sider class="app-root app-backdrop">
<template>
<n-layout has-sider class="app-shell">
<n-layout-sider
collapse-mode="width"
:collapsed-width="isFullscreen ? 0 : 72"
:collapsed-width="isFullscreen ? 0 : 84"
:width="siderWidth"
:collapsed="isFullscreen || authStore.layoutCollapsed"
:collapsed="isSiderCollapsed"
:show-trigger="false"
class="layout-sider"
:class="{
'layout-sider-collapsed': isSiderCollapsed,
'layout-sider-hidden': isFullscreen
}"
:show-trigger="!isFullscreen"
@collapse="authStore.layoutCollapsed = true"
@expand="authStore.layoutCollapsed = false"
>
<div class="brand-mark">
<img v-if="appLogo" class="brand-logo" :src="appLogo" alt="" />
<span v-else class="brand-dot"></span>
<span class="brand-name" :title="appTitle">{{ appTitle }}</span>
<div class="brand-seal">
<img v-if="appLogo" class="brand-logo" :src="appLogo" alt="" />
<span v-else class="brand-glyph">{{ appTitle.slice(0, 1) }}</span>
</div>
<strong v-if="!isSiderCollapsed" class="brand-name" :title="appTitle">{{
appTitle
}}</strong>
</div>
<div class="menu-wrap">
<AppMenu />
</div>
<div
v-if="!isSiderCollapsed"
class="sider-resizer"
@@ -32,44 +36,67 @@
<n-layout
class="app-main"
:class="{ 'app-main-fullscreen': isFullscreen }"
content-style="display:flex;flex-direction:column;height:100%;min-height:0;"
>
<header v-if="!isFullscreen" class="app-header">
<div class="header-main">
<n-button
tertiary
circle
size="small"
class="header-refresh"
title="刷新当前页面"
@click="authStore.refreshTab(route.path)"
>
<template #icon>
<n-icon><RefreshCw /></n-icon>
</template>
</n-button>
<n-button
tertiary
circle
size="small"
class="header-fullscreen"
:title="isFullscreen ? '退出全屏' : '全屏'"
@click="toggleFullscreen"
>
<template #icon>
<n-icon>
<Minimize2 v-if="isFullscreen" />
<Maximize2 v-else />
</n-icon>
</template>
</n-button>
<div class="breadcrumb-group">
<AppBreadcrumb />
<div class="header-leading">
<n-button
tertiary
circle
size="small"
class="header-icon-btn"
:title="authStore.layoutCollapsed ? '展开侧边栏' : '折叠侧边栏'"
@click="authStore.layoutCollapsed = !authStore.layoutCollapsed"
>
<template #icon>
<n-icon>
<PanelLeftOpen v-if="authStore.layoutCollapsed" />
<PanelLeftClose v-else />
</n-icon>
</template>
</n-button>
<div class="header-meta">
<h1 class="header-title">{{ currentTitle }}</h1>
<AppBreadcrumb />
</div>
</div>
<div class="summary-actions">
<n-tag type="info" size="small">{{ userDisplayName }}</n-tag>
<n-button tertiary size="small" @click="handleLogout">退出</n-button>
<span class="identity-name">{{ userDisplayName }}</span>
<n-button
tertiary
circle
size="small"
class="header-icon-btn"
title="刷新当前页面"
@click="authStore.refreshTab(route.path)"
>
<template #icon>
<n-icon><RefreshCw /></n-icon>
</template>
</n-button>
<n-button
tertiary
circle
size="small"
class="header-icon-btn"
:title="isFullscreen ? '退出全屏' : '全屏'"
@click="toggleFullscreen"
>
<template #icon>
<n-icon>
<Minimize2 v-if="isFullscreen" />
<Maximize2 v-else />
</n-icon>
</template>
</n-button>
<n-button tertiary class="logout-btn" @click="handleLogout">
<template #icon>
<n-icon><LogOut /></n-icon>
</template>
退出
</n-button>
</div>
</div>
<AppTabs />
@@ -95,12 +122,19 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { Maximize2, Minimize2, RefreshCw } from 'lucide-vue-next'
import {
LogOut,
Maximize2,
Minimize2,
PanelLeftClose,
PanelLeftOpen,
RefreshCw
} from 'lucide-vue-next'
import { useRoute, useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import AppBreadcrumb from '@/components/AppBreadcrumb.vue'
import AppMenu from '@/components/AppMenu.vue'
import AppTabs from '@/components/AppTabs.vue'
import AppBreadcrumb from '@/components/AppBreadcrumb.vue'
import { appEnv } from '@/config/env'
import { resetDynamicRoutes } from '@/router'
@@ -110,25 +144,26 @@ const authStore = useAuthStore()
const appTitle = appEnv.appTitle
const appLogo = appEnv.appLogo
const isFullscreen = ref(false)
const SIDER_WIDTH_KEY = 'platform.layout.siderWidth'
const MIN_SIDER_WIDTH = 200
const MAX_SIDER_WIDTH = 360
const DEFAULT_SIDER_WIDTH = 248
const MIN_SIDER_WIDTH = 220
const MAX_SIDER_WIDTH = 340
const DEFAULT_SIDER_WIDTH = 244
const storedSiderWidth = Number(localStorage.getItem(SIDER_WIDTH_KEY))
const siderWidth = ref(
Number.isFinite(storedSiderWidth)
? Math.min(MAX_SIDER_WIDTH, Math.max(MIN_SIDER_WIDTH, storedSiderWidth))
: DEFAULT_SIDER_WIDTH
)
const isSiderCollapsed = computed(() => isFullscreen.value || authStore.layoutCollapsed)
const isSiderCollapsed = computed(() => isFullscreen.value || authStore.layoutCollapsed)
const currentTitle = computed(() => (route.meta.title as string | undefined) ?? '工作台')
const cachedRouteNames = computed(() =>
router
.getRoutes()
.filter((item) => item.meta.keepAlive && typeof item.name === 'string')
.map((item) => item.name as string)
)
const userDisplayName = computed(() => {
const nickname = authStore.user?.nickname?.trim()
return nickname || authStore.user?.username || '访客'
@@ -200,7 +235,9 @@ async function handleLogout() {
<style scoped>
.fade-slide-enter-active,
.fade-slide-leave-active {
transition: all 0.2s ease;
transition:
opacity 0.16s ease,
transform 0.16s ease;
}
.fade-slide-enter-from,
@@ -209,85 +246,33 @@ async function handleLogout() {
transform: translateY(6px);
}
.app-root {
.app-shell {
height: 100vh;
overflow: hidden;
}
.app-main {
height: 100vh;
min-width: 0;
overflow: hidden;
display: flex;
flex-direction: column;
padding: 0;
}
.app-main-fullscreen .layout-content {
padding: 0;
}
.app-header {
flex: 0 0 auto;
border-bottom: 1px solid #e2e8f0;
background: #ffffff;
padding: 10px 18px 8px;
}
.header-main {
display: flex;
align-items: center;
gap: 12px;
min-height: 40px;
}
.header-refresh,
.header-fullscreen {
flex: 0 0 auto;
}
.breadcrumb-group {
flex: 1 1 auto;
min-width: 0;
display: flex;
align-items: center;
}
.summary-actions {
display: flex;
align-items: center;
gap: 8px;
flex: 0 0 auto;
}
.layout-content {
flex: 1 1 auto;
min-height: 0;
overflow: hidden;
padding: 16px 18px 18px;
}
.content-scroll {
height: 100%;
min-height: 0;
display: flex;
flex-direction: column;
overflow: hidden;
}
.content-scroll > * {
flex: 1 1 auto;
min-height: 0;
}
.app-backdrop {
background: #f5f7fb;
background: #f5f6f8;
}
.layout-sider {
position: relative;
background: #ffffff;
border-right: 1px solid #e2e8f0;
border-right: 1px solid #e7eaf0;
}
.layout-sider :deep(.n-layout-sider-scroll-container),
.layout-sider :deep(.n-layout-toggle-bar),
.layout-sider :deep(.n-scrollbar-container),
.layout-sider :deep(.n-scrollbar-content) {
scrollbar-width: none;
-ms-overflow-style: none;
}
.layout-sider :deep(.n-layout-sider-scroll-container::-webkit-scrollbar),
.layout-sider :deep(.n-layout-toggle-bar::-webkit-scrollbar),
.layout-sider :deep(.n-scrollbar-container::-webkit-scrollbar),
.layout-sider :deep(.n-scrollbar-content::-webkit-scrollbar) {
display: none;
width: 0;
height: 0;
}
.layout-sider-hidden {
@@ -297,40 +282,54 @@ async function handleLogout() {
.brand-mark {
display: flex;
align-items: center;
gap: 11px;
height: 64px;
padding: 0 18px;
border-bottom: 1px solid #eef2f7;
gap: 12px;
min-height: 64px;
padding: 16px;
border-bottom: 1px solid #eef1f5;
}
.brand-dot {
width: 28px;
height: 28px;
border-radius: 8px;
background: linear-gradient(135deg, #111827, #2563eb);
box-shadow: 0 10px 22px rgba(37, 99, 235, 0.18);
.brand-seal {
display: inline-flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
border-radius: 10px;
background: #111827;
color: #ffffff;
}
.brand-glyph {
font-size: 14px;
font-weight: 600;
}
.brand-logo {
width: 28px;
height: 28px;
flex: 0 0 auto;
width: 20px;
height: 20px;
object-fit: contain;
}
.brand-name {
color: #111827;
font-size: 15px;
font-weight: 700;
letter-spacing: 0;
min-width: 0;
overflow: hidden;
color: #111827;
font-size: 15px;
font-weight: 600;
text-overflow: ellipsis;
white-space: nowrap;
}
.menu-wrap {
padding: 12px 10px;
height: calc(100% - 64px);
padding: 10px 8px 14px;
overflow: hidden;
scrollbar-width: none;
-ms-overflow-style: none;
}
.menu-wrap::-webkit-scrollbar {
display: none;
}
.sider-resizer {
@@ -349,7 +348,7 @@ async function handleLogout() {
position: absolute;
top: 0;
right: 2px;
width: 2px;
width: 1px;
height: 100%;
background: transparent;
transition: background 0.16s ease;
@@ -357,7 +356,7 @@ async function handleLogout() {
.sider-resizer:hover::after,
:global(.sider-resizing) .sider-resizer::after {
background: #2563eb;
background: #d1d5db;
}
:global(.sider-resizing) {
@@ -367,10 +366,112 @@ async function handleLogout() {
.layout-sider-collapsed .brand-mark {
justify-content: center;
padding-left: 0;
padding-right: 0;
}
.layout-sider-collapsed .menu-wrap {
padding-left: 6px;
padding-right: 6px;
}
.app-main {
height: 100vh;
min-width: 0;
display: flex;
flex-direction: column;
overflow: hidden;
}
.app-header {
flex: 0 0 auto;
padding: 16px 20px 10px;
border-bottom: 1px solid #e7eaf0;
background: #ffffff;
}
.header-main {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
}
.header-leading {
display: flex;
align-items: center;
gap: 12px;
min-width: 0;
}
.header-icon-btn {
color: #4b5563;
}
.header-meta {
min-width: 0;
}
.header-title {
margin: 0 0 4px;
color: #111827;
font-size: 22px;
font-weight: 600;
}
.summary-actions {
display: flex;
align-items: center;
gap: 8px;
}
.identity-name {
color: #374151;
font-size: 13px;
}
.logout-btn {
color: #374151;
}
.layout-content {
flex: 1 1 auto;
min-height: 0;
overflow: hidden;
padding: 0;
}
.layout-sider-collapsed .brand-name {
display: none;
.content-scroll {
height: 100%;
min-height: 0;
display: flex;
flex-direction: column;
overflow: hidden;
padding: 16px 20px 20px;
}
.content-scroll > * {
flex: 1 1 auto;
min-height: 0;
}
@media (max-width: 960px) {
.app-header {
padding: 14px 16px 8px;
}
.header-main {
flex-direction: column;
align-items: stretch;
}
.summary-actions {
justify-content: flex-end;
flex-wrap: wrap;
}
.content-scroll {
padding: 14px 16px 16px;
}
}
</style>
+6 -4
View File
@@ -19,6 +19,10 @@ function normalizeTabPath(path: string) {
return path
}
function createDefaultTabs(): TabItem[] {
return [{ name: 'workbench-home', title: '工作台', path: '/dashboard', closable: false }]
}
export const useAuthStore = defineStore('auth', () => {
const token = ref<string>(localStorage.getItem(TOKEN_KEY) ?? '')
const user = ref<CurrentUserProfile | null>(null)
@@ -26,9 +30,7 @@ export const useAuthStore = defineStore('auth', () => {
const permissions = ref<string[]>([])
const layoutCollapsed = ref(false)
const dictCache = ref<Record<string, DictItem[]>>({})
const tabs = ref<TabItem[]>([
{ name: 'workbench-home', title: '工作台', path: '/dashboard', closable: false }
])
const tabs = ref<TabItem[]>(createDefaultTabs())
const tabRefreshMarks = ref<Record<string, number>>({})
const loaded = ref(false)
@@ -46,7 +48,7 @@ export const useAuthStore = defineStore('auth', () => {
permissions.value = []
dictCache.value = {}
loaded.value = false
tabs.value = [{ name: 'workbench-home', title: '工作台', path: '/dashboard', closable: false }]
tabs.value = createDefaultTabs()
tabRefreshMarks.value = {}
localStorage.removeItem(TOKEN_KEY)
}
+72 -114
View File
@@ -3,120 +3,84 @@
@tailwind utilities;
:root {
color: #0f172a;
background: #f5f7fb;
color: #111827;
background: #f5f6f8;
font-family: 'PingFang SC', 'Microsoft YaHei', 'Helvetica Neue', Arial, sans-serif;
--app-bg: #f5f6f8;
--app-surface: #ffffff;
--app-border: #e7eaf0;
--app-border-strong: #d7dce5;
--app-primary: #111827;
--app-primary-soft: #f3f4f6;
--app-text: #111827;
--app-text-muted: #6b7280;
--app-shadow: 0 1px 2px rgba(15, 23, 42, 0.04);
}
* {
box-sizing: border-box;
}
html,
body,
#app {
height: 100%;
}
body {
margin: 0;
min-height: 100vh;
overflow: hidden;
font-family: Inter, 'Avenir Next', 'PingFang SC', 'Microsoft YaHei', sans-serif;
background: #f5f7fb;
@apply text-slate-900 antialiased;
color: var(--app-text);
background: var(--app-bg);
font-family: 'PingFang SC', 'Microsoft YaHei', 'Helvetica Neue', Arial, sans-serif;
@apply antialiased;
}
#app {
height: 100vh;
overflow: hidden;
button,
input,
textarea,
select {
font: inherit;
}
.app-panel {
border: 1px solid #e2e8f0;
border-radius: 8px;
background: #ffffff;
box-shadow: 0 14px 32px rgba(15, 23, 42, 0.05);
border: 1px solid var(--app-border);
border-radius: 14px;
background: var(--app-surface);
box-shadow: var(--app-shadow);
}
.page-title {
@apply text-lg font-semibold tracking-tight text-slate-900;
}
.subtle-grid {
background-image:
linear-gradient(to right, rgba(148, 163, 184, 0.06) 1px, transparent 1px),
linear-gradient(to bottom, rgba(148, 163, 184, 0.06) 1px, transparent 1px);
background-size: 28px 28px;
}
.list-page {
height: 100%;
display: flex;
flex-direction: column;
gap: 16px;
overflow: hidden;
color: #111827;
font-size: 18px;
font-weight: 600;
}
.list-page,
.page-shell {
height: 100%;
min-height: 0;
display: flex;
flex-direction: column;
gap: 0;
gap: 12px;
overflow: hidden;
}
.page-hero {
display: none;
position: relative;
overflow: hidden;
border: 1px solid rgba(255, 255, 255, 0.76);
border-radius: 26px;
background:
radial-gradient(circle at 12% 12%, rgba(45, 212, 191, 0.24), transparent 28%),
radial-gradient(circle at 92% 18%, rgba(125, 211, 252, 0.24), transparent 30%),
linear-gradient(135deg, rgba(255, 255, 255, 0.78), rgba(240, 253, 250, 0.56));
box-shadow:
0 18px 36px rgba(14, 116, 144, 0.07),
inset 0 1px 0 rgba(255, 255, 255, 0.9);
padding: 18px 22px;
}
.page-hero::after {
content: '';
position: absolute;
right: -36px;
top: -52px;
width: 160px;
height: 160px;
border-radius: 999px;
border: 28px solid rgba(14, 165, 233, 0.08);
}
.page-hero-content {
position: relative;
z-index: 1;
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
}
.page-kicker {
display: inline-flex;
align-items: center;
gap: 8px;
margin-bottom: 6px;
color: #0f8f86;
font-size: 12px;
font-weight: 700;
letter-spacing: 0.08em;
.page-kicker,
.page-desc {
display: none;
}
.page-title-xl {
margin: 0;
color: #0f172a;
font-size: 22px;
font-weight: 800;
letter-spacing: -0.02em;
}
.page-desc {
display: none;
color: #111827;
font-size: 20px;
font-weight: 600;
}
.page-card {
@@ -124,7 +88,6 @@ body {
flex: 1;
min-height: 0;
overflow: hidden;
border-radius: 8px;
}
.page-toolbar {
@@ -132,9 +95,9 @@ body {
align-items: center;
justify-content: space-between;
gap: 12px;
border-bottom: 1px solid #e2e8f0;
padding: 14px 16px;
border-bottom: 1px solid var(--app-border);
background: #ffffff;
padding: 12px 14px;
}
.toolbar-form {
@@ -146,7 +109,7 @@ body {
}
.card-body {
padding: 14px;
padding: 16px;
}
.card-body-fill {
@@ -172,6 +135,7 @@ body {
display: flex;
flex-direction: column;
min-height: 0;
background: transparent;
}
.table-fill .n-data-table-wrapper {
@@ -179,11 +143,13 @@ body {
flex-direction: column;
flex: 1;
min-height: 0;
background: transparent;
}
.table-fill .n-data-table-base-table {
flex: 1;
min-height: 0;
background: transparent;
}
.table-fill .n-data-table-base-table-body {
@@ -195,7 +161,7 @@ body {
flex: 0 0 auto;
justify-content: flex-end;
margin-top: auto;
padding: 12px 2px 0;
padding: 12px 16px;
}
.app-vxe-table-body {
@@ -211,48 +177,29 @@ body {
min-height: 0;
}
.page-card .n-data-table {
background: transparent;
}
.page-card .n-data-table-wrapper {
background: transparent;
}
.page-card .n-data-table-base-table {
background: transparent;
}
.page-card .n-data-table-th {
font-weight: 600;
font-weight: 500;
color: #6b7280;
}
.page-card .n-data-table-td {
color: #334155;
color: #111827;
}
.page-card .n-data-table-tr:hover .n-data-table-td {
background: #fafafa;
}
.action-btn {
min-width: 64px;
min-width: 68px;
}
.soft-stat {
position: relative;
overflow: hidden;
border-radius: 8px;
border: 1px solid #e2e8f0;
border: 1px solid var(--app-border);
border-radius: 14px;
background: #ffffff;
box-shadow: 0 14px 32px rgba(15, 23, 42, 0.05);
padding: 18px 18px 16px;
}
.soft-stat::after {
content: '';
position: absolute;
left: 0;
top: 0;
width: 3px;
height: 100%;
background: linear-gradient(180deg, #2563eb, #14b8a6);
box-shadow: var(--app-shadow);
padding: 18px;
}
.n-button {
@@ -263,3 +210,14 @@ body {
.n-base-selection {
font-size: 13px;
}
.n-modal {
border: 1px solid var(--app-border);
}
@media (max-width: 960px) {
.page-toolbar {
flex-direction: column;
align-items: stretch;
}
}
+27
View File
@@ -0,0 +1,27 @@
import { useAuthStore } from '@/stores/auth'
import { router, resetDynamicRoutes } from '@/router'
let redirecting = false
export async function redirectToLogin(reason?: string) {
const authStore = useAuthStore()
const currentPath = router.currentRoute.value.fullPath
const redirect = currentPath !== '/login' ? currentPath : '/dashboard'
authStore.clearAuth()
resetDynamicRoutes()
if (redirecting) return
redirecting = true
try {
if (router.currentRoute.value.path !== '/login') {
await router.replace({
path: '/login',
query: reason ? { redirect, reason } : { redirect }
})
}
} finally {
redirecting = false
}
}
+16
View File
@@ -0,0 +1,16 @@
/**
* base64 img src data URL
*
* @param base64 - base64 data:image
* @param mime - MIME image/png
* @returns img src data URL undefined
*/
export function base64ToImageUrl(base64: string | null | undefined, mime = 'image/png'): string | undefined {
if (!base64) return undefined
const cleaned = base64.trim()
// 如果已包含 data:image 前缀则直接返回
if (cleaned.startsWith('data:image/')) return cleaned
// 如果已包含 data: 前缀但非 image,仍加 image 前缀
if (cleaned.startsWith('data:')) return cleaned
return `data:${mime};base64,${cleaned}`
}
+50
View File
@@ -0,0 +1,50 @@
import type { MessageApi, MessageOptions } from 'naive-ui'
let messageApi: MessageApi | null = null
export function setMessageApi(api: MessageApi) {
messageApi = api
}
function normalizeOptions(options?: MessageOptions): MessageOptions {
return {
keepAliveOnHover: true,
...options
}
}
function callMessage(
type: 'create' | 'info' | 'success' | 'warning' | 'error' | 'loading',
content: string,
options?: MessageOptions
) {
if (!messageApi) {
console[type === 'error' ? 'error' : 'warn'](`[message:${type}]`, content)
return null
}
return messageApi[type](content, normalizeOptions(options))
}
export const appMessage = {
create(content: string, options?: MessageOptions) {
return callMessage('create', content, options)
},
info(content: string, options?: MessageOptions) {
return callMessage('info', content, options)
},
success(content: string, options?: MessageOptions) {
return callMessage('success', content, options)
},
warning(content: string, options?: MessageOptions) {
return callMessage('warning', content, options)
},
error(content: string, options?: MessageOptions) {
return callMessage('error', content, options)
},
loading(content: string, options?: MessageOptions) {
return callMessage('loading', content, options)
},
destroyAll() {
messageApi?.destroyAll()
}
}
+37 -72
View File
@@ -1,13 +1,17 @@
<template>
<template>
<div class="login-page">
<div class="login-background"></div>
<n-card class="login-card">
<n-card class="login-card" :bordered="false">
<template #header>
<div class="text-center">
<div class="text-2xl font-semibold text-slate-900">通用管理平台</div>
<div class="login-card-header">
<div class="login-card-title">{{ appTitle }}</div>
<div class="login-card-subtitle">登录后继续使用</div>
</div>
</template>
<n-alert v-if="route.query.reason === 'expired'" type="warning" class="login-alert">
未登录或登录已失效请重新登录
</n-alert>
<n-form ref="formRef" :model="form" :rules="rules" size="large" @submit.prevent="onSubmit">
<n-form-item path="username" label="用户名">
<n-input v-model:value="form.username" placeholder="请输入用户名" />
@@ -20,9 +24,9 @@
placeholder="请输入密码"
/>
</n-form-item>
<n-button type="primary" block size="large" :loading="loading" attr-type="submit"
>登录</n-button
>
<n-button type="primary" block size="large" :loading="loading" attr-type="submit">
登录
</n-button>
</n-form>
</n-card>
</div>
@@ -33,12 +37,14 @@ import { reactive, ref } from 'vue'
import type { FormInst, FormRules } from 'naive-ui'
import { useMessage } from 'naive-ui'
import { useRoute, useRouter } from 'vue-router'
import { appEnv } from '@/config/env'
import { useAuthStore } from '@/stores/auth'
const router = useRouter()
const route = useRoute()
const message = useMessage()
const authStore = useAuthStore()
const appTitle = appEnv.appTitle
const loading = ref(false)
const formRef = ref<FormInst | null>(null)
@@ -68,80 +74,39 @@ async function onSubmit() {
<style scoped>
.login-page {
position: relative;
display: flex;
min-height: 100vh;
align-items: center;
justify-content: flex-end;
overflow: hidden;
padding: 24px clamp(24px, 9vw, 128px) 24px 16px;
background:
linear-gradient(
42deg,
rgba(226, 252, 244, 0.98),
rgba(224, 244, 255, 0.96),
rgba(241, 255, 250, 0.98),
rgba(230, 247, 255, 0.96),
rgba(226, 252, 244, 0.98)
),
#f5fbff;
background-size: 320% 320%;
animation: loginGradientFlow 18s ease-in-out infinite;
}
.login-background {
position: absolute;
inset: 0;
background:
linear-gradient(90deg, rgba(14, 116, 144, 0.05) 1px, transparent 1px),
linear-gradient(180deg, rgba(15, 118, 110, 0.04) 1px, transparent 1px),
linear-gradient(145deg, transparent 0%, rgba(255, 255, 255, 0.76) 46%, transparent 74%);
background-size:
42px 42px,
42px 42px,
100% 100%;
opacity: 0.72;
}
.login-page::before {
content: '';
position: absolute;
inset: 0;
background:
linear-gradient(115deg, rgba(255, 255, 255, 0.82), transparent 35%),
linear-gradient(295deg, rgba(204, 251, 241, 0.34), transparent 42%);
justify-content: center;
min-height: 100vh;
padding: 24px;
background: #f5f6f8;
}
.login-card {
position: relative;
z-index: 1;
width: 100%;
max-width: 448px;
border: 1px solid rgba(255, 255, 255, 0.78);
max-width: 420px;
border-radius: 16px;
background: rgba(255, 255, 255, 0.82);
box-shadow: 0 24px 60px rgba(15, 118, 110, 0.12);
backdrop-filter: blur(18px);
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.06);
}
@keyframes loginGradientFlow {
0% {
background-position: 0% 50%;
}
50% {
background-position: 100% 50%;
}
100% {
background-position: 0% 50%;
}
.login-card-header {
display: flex;
flex-direction: column;
gap: 6px;
}
@media (max-width: 768px) {
.login-page {
justify-content: center;
padding: 24px 16px;
}
.login-card-title {
color: #111827;
font-size: 26px;
font-weight: 600;
}
.login-card-subtitle {
color: #6b7280;
font-size: 13px;
}
.login-alert {
margin-bottom: 18px;
}
</style>