蓝红票功能
This commit is contained in:
+6
-51
@@ -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
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
+852
-106
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
@@ -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>
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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}`
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user