整合前端仓库并保留提交历史
@@ -0,0 +1,188 @@
|
||||
import request from '~/utils/request'
|
||||
|
||||
/**
|
||||
* 用户登录认证
|
||||
*/
|
||||
export function authenticateUser(data: {
|
||||
username: string
|
||||
password: string
|
||||
}): Promise<any> {
|
||||
return request.post('/auth/login', data)
|
||||
}
|
||||
|
||||
/**
|
||||
* 手机验证码登录
|
||||
*/
|
||||
export function authenticateWithMobile(data: {
|
||||
mobile: string
|
||||
code: string
|
||||
key: string
|
||||
}): Promise<any> {
|
||||
return request.post('/auth/login/mobile', data)
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户退出登录
|
||||
*/
|
||||
export function logoutUser(): Promise<boolean> {
|
||||
return request.post('/auth/logout')
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户注册
|
||||
*/
|
||||
export function registerUser(data: {
|
||||
username: string
|
||||
email: string
|
||||
password: string
|
||||
confirm_password: string
|
||||
pid?: string
|
||||
}): Promise<any> {
|
||||
const url = data.pid ? `/auth/register?pid=${data.pid}` : '/auth/register'
|
||||
return request.post(url, data)
|
||||
}
|
||||
|
||||
/**
|
||||
* 手机号注册
|
||||
*/
|
||||
export function registerWithMobile(data: {
|
||||
mobile: string
|
||||
code: string
|
||||
key: string
|
||||
pid?: string
|
||||
}): Promise<any> {
|
||||
const url = data.pid ? `/auth/register/mobile?pid=${data.pid}` : '/auth/register/mobile'
|
||||
return request.post(url, data)
|
||||
}
|
||||
|
||||
/**
|
||||
* 微信授权登录
|
||||
*/
|
||||
export function authenticateWithWechat(data: any): Promise<any> {
|
||||
return request.post('/auth/wechat', data)
|
||||
}
|
||||
|
||||
/**
|
||||
* 微信小程序登录
|
||||
*/
|
||||
export function authenticateWithWeapp(data: any): Promise<any> {
|
||||
return request.post('/auth/weapp', data)
|
||||
}
|
||||
|
||||
/**
|
||||
* 绑定手机号
|
||||
*/
|
||||
export function bindMobile(data: {
|
||||
mobile: string
|
||||
code: string
|
||||
pid?: string
|
||||
}): Promise<any> {
|
||||
const url = data.pid ? `/auth/bind?pid=${data.pid}` : '/auth/bind'
|
||||
return request.post(url, data)
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成微信扫码登录二维码
|
||||
*/
|
||||
export function generateWechatQrCode(): Promise<any> {
|
||||
return request.post('/auth/wechat/scan')
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查微信扫码状态
|
||||
*/
|
||||
export function checkWechatScanStatus(data: { scene_id: string }): Promise<any> {
|
||||
return request.get('/auth/wechat/scan/status', data)
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查微信登录可用性
|
||||
*/
|
||||
export function checkWechatAvailability(): Promise<boolean> {
|
||||
return request.get('/auth/wechat/availability')
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取第三方绑定列表
|
||||
*/
|
||||
export function getThirdPartyList(): Promise<any[]> {
|
||||
return request.get('/auth/third-party/list')
|
||||
}
|
||||
|
||||
/**
|
||||
* 绑定第三方账号
|
||||
*/
|
||||
export function bindThirdParty(data: { platform: number }): Promise<boolean> {
|
||||
return request.post('/auth/third-party/bind', data)
|
||||
}
|
||||
|
||||
/**
|
||||
* 解绑第三方账号
|
||||
*/
|
||||
export function unbindThirdParty(platform: number): Promise<boolean> {
|
||||
return request.delete(`/auth/third-party/unbind/${platform}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成第三方绑定二维码
|
||||
*/
|
||||
export function generateBindQrCode(platform: number): Promise<any> {
|
||||
return request.post('/auth/third-party/qr-code', { platform })
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查绑定二维码状态
|
||||
*/
|
||||
export function checkBindQrStatus(sceneId: string): Promise<any> {
|
||||
return request.get('/auth/third-party/qr-status', { scene_id: sceneId })
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证邮箱(第一步:验证邮箱和验证码)
|
||||
*/
|
||||
export function verifyEmail(data: {
|
||||
email: string
|
||||
code: string
|
||||
}): Promise<boolean> {
|
||||
return request.post('/auth/verify-email', data)
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送邮箱验证码
|
||||
*/
|
||||
export function sendEmailCode(data: {
|
||||
email: string
|
||||
captcha_key: string
|
||||
captcha_code: string
|
||||
}): Promise<boolean> {
|
||||
return request.post('/auth/send-email-code', data)
|
||||
}
|
||||
|
||||
/**
|
||||
* 忘记密码 - 重置密码(第二步:设置新密码)
|
||||
*/
|
||||
export function forgetPassword(data: {
|
||||
email: string
|
||||
code: string
|
||||
new_password: string
|
||||
confirm_password: string
|
||||
}): Promise<boolean> {
|
||||
return request.post('/auth/forget-password', data)
|
||||
}
|
||||
|
||||
/**
|
||||
* 刷新 Token
|
||||
*/
|
||||
export function refreshToken(refresh_token: string): Promise<{
|
||||
code: number
|
||||
msg: string
|
||||
data?: {
|
||||
access_token: string
|
||||
refresh_token: string
|
||||
}
|
||||
}> {
|
||||
return request.post('/auth/refresh', { refresh_token }, {
|
||||
// 不带 token 请求,避免 401 循环
|
||||
headers: {}
|
||||
} as any)
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
/**
|
||||
* 认证模块相关类型定义
|
||||
*/
|
||||
|
||||
/**
|
||||
* 登录数据
|
||||
*/
|
||||
export interface LoginData {
|
||||
username?: string
|
||||
password?: string
|
||||
phone?: string
|
||||
verify_code?: string
|
||||
pid?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 注册数据
|
||||
*/
|
||||
export interface RegisterData {
|
||||
username?: string
|
||||
password?: string
|
||||
phone?: string
|
||||
verify_code?: string
|
||||
pid?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 绑定手机号数据
|
||||
*/
|
||||
export interface BindMobileData {
|
||||
mobile: string
|
||||
code: string
|
||||
pid?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户信息(登录/注册后返回)
|
||||
*/
|
||||
export interface UserInfo {
|
||||
id: number
|
||||
username: string
|
||||
nickname?: string
|
||||
avatar?: string
|
||||
phone?: string
|
||||
email?: string
|
||||
token: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 登录配置
|
||||
*/
|
||||
export interface LoginConfig {
|
||||
enable_username_login: boolean
|
||||
enable_mobile_login: boolean
|
||||
enable_wechat_login: boolean
|
||||
enable_weapp_login: boolean
|
||||
enable_register: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* 扫码状态
|
||||
*/
|
||||
export enum ScanStatus {
|
||||
WAITING = 'waiting',
|
||||
SCANNED = 'scanned',
|
||||
CONFIRMED = 'confirmed',
|
||||
EXPIRED = 'expired',
|
||||
CANCELED = 'canceled'
|
||||
}
|
||||
|
||||
/**
|
||||
* 第三方平台类型
|
||||
*/
|
||||
export enum ThirdPartyPlatform {
|
||||
WECHAT = 1,
|
||||
QQ = 2,
|
||||
ALIPAY = 3
|
||||
}
|
||||
|
||||
/**
|
||||
* 第三方绑定信息
|
||||
*/
|
||||
export interface ThirdPartyBinding {
|
||||
platform: number
|
||||
platform_name: string
|
||||
nickname?: string
|
||||
avatar?: string
|
||||
bind_time: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 扫码登录信息
|
||||
*/
|
||||
export interface QrCodeLoginInfo {
|
||||
scene_id: string
|
||||
qrcode_url: string
|
||||
expires_in: number
|
||||
}
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
import request from '~/utils/request'
|
||||
import type {
|
||||
MemberInfo,
|
||||
Address,
|
||||
PointTransactionsResponse,
|
||||
BalanceTransactionsResponse,
|
||||
SignCalendar,
|
||||
SignStatistics,
|
||||
MemberLevel,
|
||||
SignStatus
|
||||
} from './types'
|
||||
|
||||
/**
|
||||
* 获取会员基本信息
|
||||
*/
|
||||
export function getMemberProfile(): Promise<MemberInfo> {
|
||||
return request.get('/member/user-info')
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新会员信息
|
||||
*/
|
||||
export function updateMemberInfo(data: Partial<MemberInfo>): Promise<boolean> {
|
||||
return request.put('/member/user/update', data)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取积分流水记录
|
||||
*/
|
||||
export function getPointTransactions(data: {
|
||||
page?: number
|
||||
page_size?: number
|
||||
}): Promise<PointTransactionsResponse> {
|
||||
return request.get('/member/points/record', data)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取会员积分总额
|
||||
*/
|
||||
export function getMemberPointsTotal(): Promise<number> {
|
||||
return request.get('/member/points/total')
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取余额流水记录
|
||||
*/
|
||||
export function getBalanceTransactions(data: {
|
||||
page?: number
|
||||
page_size?: number
|
||||
}): Promise<BalanceTransactionsResponse> {
|
||||
return request.get('/member/balance/record', data)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有余额流水记录
|
||||
*/
|
||||
export function getAllBalanceTransactions(data?: {
|
||||
page?: number
|
||||
page_size?: number
|
||||
}): Promise<BalanceTransactionsResponse> {
|
||||
return request.get('/member/balance/all', data)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取会员等级信息
|
||||
*/
|
||||
export function getMemberLevel(): Promise<MemberLevel> {
|
||||
return request.get('/member/points/level')
|
||||
}
|
||||
|
||||
/**
|
||||
* 会员签到
|
||||
*/
|
||||
export function memberSign(): Promise<number> {
|
||||
return request.post('/member/points/sign')
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行签到
|
||||
*/
|
||||
export function signIn(): Promise<boolean> {
|
||||
return request.post('/member/sign')
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取签到状态
|
||||
*/
|
||||
export function getSignStatus(): Promise<SignStatus> {
|
||||
return request.get('/member/sign/status')
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取签到日历
|
||||
*/
|
||||
export function getSignCalendar(year: number, month: number): Promise<SignCalendar> {
|
||||
return request.get('/member/sign/calendar', { year, month })
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取签到统计
|
||||
*/
|
||||
export function getSignStatistics(type: string = 'month'): Promise<SignStatistics> {
|
||||
return request.get('/member/sign/statistics', { type })
|
||||
}
|
||||
|
||||
/**
|
||||
* 修改密码
|
||||
*/
|
||||
export function changePassword(data: {
|
||||
old_password: string
|
||||
new_password: string
|
||||
confirm_password: string
|
||||
}): Promise<boolean> {
|
||||
return request.put('/member/user/change-password', data)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取地址列表
|
||||
*/
|
||||
export function getAddressList(): Promise<Address[]> {
|
||||
return request.get('/member/user/address/list')
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建地址
|
||||
*/
|
||||
export function createAddress(data: Partial<Address>): Promise<Address> {
|
||||
return request.post('/member/user/address/create', data)
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新地址
|
||||
*/
|
||||
export function updateAddress(id: number, data: Partial<Address>): Promise<boolean> {
|
||||
return request.put(`/member/user/address/update/${id}`, data)
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除地址
|
||||
*/
|
||||
export function deleteAddress(id: number): Promise<boolean> {
|
||||
return request.delete(`/member/user/address/delete/${id}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取验证码
|
||||
*/
|
||||
export function getVerificationCode(data: { type: string; phone: string }): Promise<boolean> {
|
||||
return request.post('/member/user/send-code', data)
|
||||
}
|
||||
|
||||
/**
|
||||
* 修改手机号码
|
||||
*/
|
||||
export function updateMemberMobile(data: {
|
||||
oldMobile: string
|
||||
newMobile: string
|
||||
code: string
|
||||
}): Promise<boolean> {
|
||||
return request.put('/member/user/update-phone', {
|
||||
old_phone: data.oldMobile,
|
||||
new_phone: data.newMobile,
|
||||
verify_code: data.code
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 修改邮箱
|
||||
*/
|
||||
export function updateMemberEmail(data: { email: string; code: string }): Promise<boolean> {
|
||||
return request.put('/member/user/update-email', {
|
||||
email: data.email,
|
||||
verify_code: data.code
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传头像
|
||||
*/
|
||||
export function uploadAvatar(file: File): Promise<string> {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
return request.post('/member/user/avatar', formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data'
|
||||
} as any
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
/**
|
||||
* 会员模块相关类型定义
|
||||
*/
|
||||
|
||||
/**
|
||||
* 会员基本信息
|
||||
*/
|
||||
export interface MemberInfo {
|
||||
id: number
|
||||
username: string
|
||||
nickname?: string
|
||||
avatar?: string
|
||||
phone?: string
|
||||
email?: string
|
||||
points: number
|
||||
balance: number
|
||||
level: number
|
||||
level_name?: string
|
||||
created_at: number
|
||||
updated_at: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 地址
|
||||
*/
|
||||
export interface Address {
|
||||
id: number
|
||||
user_id: number
|
||||
consignee: string
|
||||
phone: string
|
||||
province: string
|
||||
city: string
|
||||
district: string
|
||||
address: string
|
||||
postal_code?: string
|
||||
is_default: number
|
||||
created_at: number
|
||||
updated_at: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 积分流水记录
|
||||
*/
|
||||
export interface PointTransaction {
|
||||
id: number
|
||||
user_id: number
|
||||
type: string
|
||||
amount: number
|
||||
balance: number
|
||||
description: string
|
||||
created_at: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 积分流水响应
|
||||
*/
|
||||
export interface PointTransactionsResponse {
|
||||
total: number
|
||||
list: PointTransaction[]
|
||||
}
|
||||
|
||||
/**
|
||||
* 余额流水记录
|
||||
*/
|
||||
export interface BalanceTransaction {
|
||||
id: number
|
||||
user_id: number
|
||||
type: string
|
||||
amount: number
|
||||
balance: number
|
||||
description: string
|
||||
created_at: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 余额流水响应
|
||||
*/
|
||||
export interface BalanceTransactionsResponse {
|
||||
total: number
|
||||
list: BalanceTransaction[]
|
||||
}
|
||||
|
||||
/**
|
||||
* 签到日历
|
||||
*/
|
||||
export interface SignCalendar {
|
||||
year: number
|
||||
month: number
|
||||
signed_dates: number[]
|
||||
total_signed: number
|
||||
total_can_sign: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 签到统计
|
||||
*/
|
||||
export interface SignStatistics {
|
||||
total_signed: number
|
||||
continuous_signed: number
|
||||
this_month_signed: number
|
||||
today_signed: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* 会员等级信息
|
||||
*/
|
||||
export interface MemberLevel {
|
||||
level: number
|
||||
level_name: string
|
||||
points: number
|
||||
next_level_points?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 签到状态
|
||||
*/
|
||||
export interface SignStatus {
|
||||
today_signed: boolean
|
||||
continuous_days: number
|
||||
can_sign: boolean
|
||||
}
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import request from '~/utils/request'
|
||||
import type { LinkItem, AdvertisementItem, NavigationItem, SiteData } from './types'
|
||||
|
||||
/**
|
||||
* 获取友情链接列表
|
||||
*/
|
||||
export function getLinks(params?: { type?: string }): Promise<LinkItem[]> {
|
||||
return request.get('/site/link', params)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有友情链接
|
||||
*/
|
||||
export function getAllLinks(): Promise<LinkItem[]> {
|
||||
return request.get('/site/link/all')
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取广告位信息
|
||||
*/
|
||||
export function getAdvertisements(params?: { type?: string }): Promise<AdvertisementItem[]> {
|
||||
return request.get('/site/ads', params)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取导航菜单列表
|
||||
*/
|
||||
export function getNavigationList(): Promise<NavigationItem[]> {
|
||||
return request.get('/site/nav')
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取站点首页数据(广告、导航、友情链接等)
|
||||
*/
|
||||
export function getSiteData(): Promise<SiteData> {
|
||||
return request.get('/site/site-data')
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 Web 分组所有配置
|
||||
* 一次性获取前台站点设置、搜索配置、统计分析配置等
|
||||
*/
|
||||
export function getWebGroupConfigs(): Promise<Record<string, any>> {
|
||||
return request.get('/system/config/group/web')
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据配置编码获取单一配置
|
||||
* @param code 配置编码,如 web_site_setting, web_payment_config 等
|
||||
*/
|
||||
export function getConfigByCode(code: string): Promise<Record<string, any>> {
|
||||
return request.get(`/system/config/code/${code}`)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* 站点首页模块相关类型定义
|
||||
*/
|
||||
|
||||
/**
|
||||
* 友情链接项
|
||||
*/
|
||||
export interface LinkItem {
|
||||
id: number
|
||||
name: string
|
||||
url: string
|
||||
logo?: string
|
||||
description?: string
|
||||
category?: string
|
||||
sort: number
|
||||
target?: string
|
||||
click_count?: number
|
||||
enabled: number
|
||||
created_at: number
|
||||
updated_at: number
|
||||
deleted_at: number | null
|
||||
}
|
||||
|
||||
/**
|
||||
* 广告项
|
||||
*/
|
||||
export interface AdvertisementItem {
|
||||
id: number
|
||||
title: string
|
||||
link: string
|
||||
image: string
|
||||
description?: string
|
||||
sort: number
|
||||
enabled: number
|
||||
start_time: number
|
||||
end_time: number
|
||||
created_at: number
|
||||
updated_at: number
|
||||
deleted_at: number | null
|
||||
}
|
||||
|
||||
/**
|
||||
* 导航菜单项
|
||||
*/
|
||||
export interface NavigationItem {
|
||||
id: number
|
||||
title: string
|
||||
url: string
|
||||
icon?: string
|
||||
code: string | null
|
||||
sort: number
|
||||
enabled: number
|
||||
is_show: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 站点首页数据
|
||||
*/
|
||||
export interface SiteData {
|
||||
advertisements: AdvertisementItem[]
|
||||
navigation: NavigationItem[]
|
||||
links: LinkItem[]
|
||||
}
|
||||
|
||||
/**
|
||||
* 搜索配置项
|
||||
*/
|
||||
export interface SearchConfig {
|
||||
keyword: string // 搜索引擎关键字
|
||||
keyword_enabled: number // 是否启用关键字搜索 (0/1)
|
||||
}
|
||||
|
||||
/**
|
||||
* 统计分析配置项
|
||||
*/
|
||||
export interface AnalyticsConfig {
|
||||
baidu_tongji_code: string // 百度统计代码
|
||||
baidu_tongji_enabled: number // 是否启用百度统计 (0/1)
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import request from '~/utils/request'
|
||||
import type { SiteConfig, AgreementType, SmsType } from './types'
|
||||
|
||||
/**
|
||||
* 按分组获取配置
|
||||
*/
|
||||
export function getConfigByGroup(groupCode: string, options?: {
|
||||
enabled_only?: boolean
|
||||
with_metadata?: boolean
|
||||
}) {
|
||||
return request.get(`/system/config/group/${groupCode}`, options)
|
||||
}
|
||||
|
||||
/**
|
||||
* 按 code 获取配置
|
||||
*/
|
||||
export function getConfigByCode(code: string, options?: { group_code?: string}) {
|
||||
return request.get(`/system/config/code/${code}`, options)
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 获取协议内容
|
||||
*/
|
||||
export function getAgreementContent(key: AgreementType): Promise<string> {
|
||||
return request.get(`/agreement/${key}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送短信验证码
|
||||
*/
|
||||
export function sendSmsVerificationCode(data: {
|
||||
type: SmsType
|
||||
phone: string
|
||||
[key: string]: any
|
||||
}): Promise<boolean> {
|
||||
return request.post(`/sms/${data.type}`, data)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取验证码图片
|
||||
*/
|
||||
export function getCaptchaImage() {
|
||||
return request.get('/captcha', { time: Date.now() }, {
|
||||
showSuccessMessage: false,
|
||||
returnFullResponse: true
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 获取远程图片
|
||||
*/
|
||||
export function fetchRemoteImage(data: { url: string }): Promise<string> {
|
||||
return request.post('/image/fetch', data)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 Base64 格式图片
|
||||
*/
|
||||
export function fetchBase64Image(data: { url: string }): Promise<string> {
|
||||
return request.post('/image/base64', data)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* 系统配置相关类型定义
|
||||
*/
|
||||
|
||||
/**
|
||||
* 配置项
|
||||
*/
|
||||
export interface ConfigItem {
|
||||
id: number
|
||||
group_code: string
|
||||
code: string
|
||||
name: string
|
||||
content: Record<string, any>
|
||||
is_sys: number
|
||||
enabled: number
|
||||
created_at: number
|
||||
updated_at: number
|
||||
remark?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 站点设置配置
|
||||
*/
|
||||
export interface SiteConfig {
|
||||
copyright: string
|
||||
icp: string
|
||||
icp_url: string
|
||||
network_security: string
|
||||
network_security_url: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 协议类型
|
||||
*/
|
||||
export type AgreementType = 'user' | 'privacy' | 'service'
|
||||
|
||||
/**
|
||||
* 短信/邮箱验证码类型
|
||||
*/
|
||||
export type SmsType = 'register' | 'login' | 'reset_password' | 'reset_password_email' | 'change_phone' | 'change_email'
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
<template>
|
||||
<div>
|
||||
<!-- SEO 组件 -->
|
||||
<Seo />
|
||||
|
||||
<el-config-provider :value-on-clear="() => null" :locale="locale">
|
||||
<NuxtLayout>
|
||||
<NuxtPage />
|
||||
</NuxtLayout>
|
||||
</el-config-provider>
|
||||
|
||||
<!-- 百度统计组件 -->
|
||||
<BaiduAnalytics />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// @ts-ignore
|
||||
import zhCn from 'element-plus/dist/locale/zh-cn.mjs'
|
||||
// @ts-ignore
|
||||
import en from 'element-plus/dist/locale/en.mjs'
|
||||
import type { Language } from 'element-plus/es/locale'
|
||||
import { onMounted, computed } from 'vue'
|
||||
import { initDark, getDark } from './utils/dark'
|
||||
import { useHead } from 'nuxt/app'
|
||||
|
||||
// modules import mark, Please do not remove.
|
||||
import { useSystemStore } from './stores/system'
|
||||
import { useMemberStore } from './stores/member'
|
||||
|
||||
const systemStore = useSystemStore()
|
||||
const locale = computed(() => (systemStore.lang === 'zh-cn' ? zhCn : en))
|
||||
|
||||
// 在服务器端渲染时就设置正确的 dark 类
|
||||
useHead({
|
||||
htmlAttrs: {
|
||||
class: getDark() ? 'dark' : ''
|
||||
}
|
||||
})
|
||||
|
||||
// 仅在客户端执行,避免 SSR 时清空持久化的 token
|
||||
if (import.meta.client) {
|
||||
const memberStore = useMemberStore()
|
||||
// 如果有 token 但没有 refreshToken(比如旧数据),清除它
|
||||
if (memberStore.token && !memberStore.refreshToken) {
|
||||
memberStore.token = ''
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await systemStore.getSiteInfoFn()
|
||||
// 初始化语言设置
|
||||
systemStore.initLanguage()
|
||||
// 初始化暗黑模式(确保客户端与服务器端保持一致)
|
||||
initDark()
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,167 @@
|
||||
import request from '~/utils/request'
|
||||
|
||||
export interface ProduceOption {
|
||||
label: string
|
||||
value: string | number
|
||||
}
|
||||
|
||||
export interface ProduceRecordItem {
|
||||
id?: string | number
|
||||
order_no?: string
|
||||
process_order_no?: string
|
||||
product_code?: string
|
||||
quantity?: number
|
||||
user_code?: string
|
||||
operator?: string
|
||||
scan_result?: number
|
||||
scan_date?: string
|
||||
created_date?: string
|
||||
process?: {
|
||||
process_name?: string
|
||||
}
|
||||
step?: {
|
||||
step_name?: string
|
||||
}
|
||||
status?: {
|
||||
status_name?: string
|
||||
}
|
||||
}
|
||||
|
||||
export interface ProduceSubmitScanDataResult {
|
||||
code: number
|
||||
msg: string
|
||||
data?: {
|
||||
id?: string | number
|
||||
}
|
||||
}
|
||||
|
||||
export interface ProduceStatistics {
|
||||
today_total: number
|
||||
success_total: number
|
||||
failed_total: number
|
||||
}
|
||||
|
||||
export interface ProduceUserStatsItem {
|
||||
user_code: string
|
||||
operator?: string
|
||||
count: number
|
||||
}
|
||||
|
||||
export interface ProduceMemberUserCheckResult {
|
||||
id: number
|
||||
username: string
|
||||
nickname: string
|
||||
}
|
||||
|
||||
export interface ProduceDataEntryStepRule {
|
||||
id?: string | number
|
||||
process_id?: string | number
|
||||
process_name?: string
|
||||
step_code?: string
|
||||
step_name?: string
|
||||
prerequisite_steps?: Array<string | number>
|
||||
prerequisite_mode?: string
|
||||
prerequisite_require_final?: number
|
||||
allow_repeat_scan?: number
|
||||
repeat_scan_statuses?: Array<string | number>
|
||||
}
|
||||
|
||||
export interface ProduceDataEntrySpecificRule {
|
||||
id?: string | number
|
||||
rule_name?: string
|
||||
rule_pattern?: string
|
||||
rule_type?: number
|
||||
apply_mode?: 'order' | 'machine' | 'both'
|
||||
match_field?: 'order_no' | 'process_order_no' | 'product_code' | 'user_code'
|
||||
step_ids?: Array<string | number>
|
||||
sort?: number
|
||||
remark?: string
|
||||
}
|
||||
|
||||
export interface ProduceDataEntryConfig {
|
||||
basic_setting: Record<string, any>
|
||||
step_rule: ProduceDataEntryStepRule | null
|
||||
specific_rules: ProduceDataEntrySpecificRule[]
|
||||
current?: {
|
||||
process_id?: string | number
|
||||
step_id?: string | number
|
||||
order_no?: string
|
||||
apply_mode?: 'order' | 'machine' | 'both'
|
||||
}
|
||||
}
|
||||
|
||||
export function getProcessOptions() {
|
||||
return request.get<ProduceOption[]>('/produce/process', { format: 'select' })
|
||||
}
|
||||
|
||||
export function getStepOptions(processId?: string | number) {
|
||||
return request.get<ProduceOption[]>('/produce/process-step', {
|
||||
format: 'select',
|
||||
...(processId ? { process_id: processId } : {}),
|
||||
})
|
||||
}
|
||||
|
||||
export function getStatusOptions(stepId?: string | number) {
|
||||
return request.get<ProduceOption[]>('/produce/step-status', {
|
||||
format: 'select',
|
||||
...(stepId ? { step_id: stepId } : {}),
|
||||
})
|
||||
}
|
||||
|
||||
export function getProduceConfig(groupCode: string) {
|
||||
return request.get<Record<string, any>>('/produce/config/info', { group_code: groupCode })
|
||||
}
|
||||
|
||||
export function getDataEntryConfig(params: Record<string, any>) {
|
||||
return request.get<ProduceDataEntryConfig>('/produce/config/data-entry', params)
|
||||
}
|
||||
|
||||
export function getRecentRecords(params: Record<string, any>) {
|
||||
return request.get<{ items: ProduceRecordItem[]; total: number }>('/produce/scan-data', {
|
||||
limit: 10,
|
||||
page: 1,
|
||||
...params,
|
||||
})
|
||||
}
|
||||
|
||||
export function getProductRecentRecords(params: Record<string, any>) {
|
||||
return request.get<{ items: ProduceRecordItem[]; total: number }>('/produce/product-record/recent', {
|
||||
limit: 10,
|
||||
page: 1,
|
||||
...params,
|
||||
})
|
||||
}
|
||||
|
||||
export function getStatistics(params: Record<string, any>) {
|
||||
return request.get<ProduceStatistics>('/produce/scan-data/statistics', params, { showErrorMessage: false })
|
||||
}
|
||||
|
||||
export function getProductStatistics(params: Record<string, any>) {
|
||||
return request.get<ProduceStatistics>('/produce/product-record/statistics', params, { showErrorMessage: false })
|
||||
}
|
||||
|
||||
export function getUserCodeStats(params: Record<string, any>) {
|
||||
return request.get<ProduceUserStatsItem[]>('/produce/scan-data/user-code-stats', params, { showErrorMessage: false })
|
||||
}
|
||||
|
||||
export function getProductUserCodeStats(params: Record<string, any>) {
|
||||
return request.get<ProduceUserStatsItem[]>('/produce/product-record/user-code-stats', params, { showErrorMessage: false })
|
||||
}
|
||||
|
||||
export function checkMemberUser(userCode: string) {
|
||||
return request.get<ProduceMemberUserCheckResult>('/produce/member-user/check', { user_code: userCode }, { showErrorMessage: false })
|
||||
}
|
||||
|
||||
export function submitScanData(data: Record<string, any>) {
|
||||
return request.post<ProduceSubmitScanDataResult>('/produce/scan-data/record', data, {
|
||||
showErrorMessage: false,
|
||||
returnFullResponse: true,
|
||||
})
|
||||
}
|
||||
|
||||
export function submitProductRecord(data: Record<string, any>) {
|
||||
return request.post<ProduceSubmitScanDataResult>('/produce/product-record/record', data, {
|
||||
showErrorMessage: false,
|
||||
returnFullResponse: true,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import request from '~/utils/request'
|
||||
|
||||
export interface ProduceExceptionSetting {
|
||||
id: string | number
|
||||
parent_id?: string | number | null
|
||||
setting_type: 'type' | 'reason' | 'issue' | string
|
||||
code?: string
|
||||
name: string
|
||||
sort?: number
|
||||
frequency?: number
|
||||
remark?: string
|
||||
}
|
||||
|
||||
export interface ProduceExceptionRecord {
|
||||
id: string | number
|
||||
order_no?: string
|
||||
user_code?: string
|
||||
reporter?: string
|
||||
type_id?: string | number
|
||||
type_name?: string
|
||||
reason_id?: string | number
|
||||
reason_name?: string
|
||||
issue_id?: string | number | null
|
||||
issue_name?: string
|
||||
images?: string[]
|
||||
remark?: string
|
||||
status?: number
|
||||
status_name?: string
|
||||
reported_date?: string
|
||||
handled_date?: string
|
||||
handler?: string
|
||||
}
|
||||
|
||||
export interface ProduceRepairTask {
|
||||
id: string | number
|
||||
device_no: string
|
||||
type_name?: string
|
||||
reason_name?: string
|
||||
issue_name?: string
|
||||
reporter?: string
|
||||
user_code?: string
|
||||
reported_date?: string
|
||||
status?: number
|
||||
status_name?: string
|
||||
}
|
||||
|
||||
export interface ProduceRepairPart {
|
||||
product_code: string
|
||||
product_name?: string
|
||||
category_id?: string | number
|
||||
category_name?: string
|
||||
source?: 'new' | 'after_sales' | 'refurbished' | string
|
||||
quantity?: number
|
||||
fault_description_ids?: Array<string | number>
|
||||
}
|
||||
|
||||
export interface ProduceScanRecord {
|
||||
id: string | number
|
||||
process_name?: string
|
||||
step_name?: string
|
||||
operator?: string
|
||||
user_code?: string
|
||||
}
|
||||
|
||||
export function getExceptionSettings() {
|
||||
return request.get<ProduceExceptionSetting[]>('/produce/exception/settings', {}, { showErrorMessage: false })
|
||||
}
|
||||
|
||||
export function getExceptionStatistics(params: Record<string, any> = {}) {
|
||||
return request.get<{ pending: number; review: number; done: number }>('/produce/exception/statistics', params, { showErrorMessage: false })
|
||||
}
|
||||
|
||||
export function getRecentExceptions(params: Record<string, any> = {}) {
|
||||
return request.get<ProduceExceptionRecord[]>('/produce/exception/recent', params, { showErrorMessage: false })
|
||||
}
|
||||
|
||||
export function checkExceptionOrder(orderNo: string) {
|
||||
return request.get<Record<string, any>>('/produce/exception/order-check', { order_no: orderNo.trim() }, { showErrorMessage: false })
|
||||
}
|
||||
|
||||
export function submitExceptionRecord(data: Record<string, any>) {
|
||||
return request.post<{ code: number; msg: string; data?: { id: string | number } }>('/produce/exception/record', data, {
|
||||
showErrorMessage: false,
|
||||
returnFullResponse: true,
|
||||
})
|
||||
}
|
||||
|
||||
export function getRepairTasks(params: Record<string, any> = {}) {
|
||||
return request.get<ProduceRepairTask[]>('/produce/repair-record/tasks', params, { showErrorMessage: false })
|
||||
}
|
||||
|
||||
export function getRepairStatistics(params: Record<string, any> = {}) {
|
||||
return request.get<{ pending: number; processing: number; completed_today: number }>('/produce/repair-record/statistics', params, { showErrorMessage: false })
|
||||
}
|
||||
|
||||
export function getRepairTask(deviceNo: string) {
|
||||
return request.get<ProduceRepairTask>('/produce/exception/repair-task', { device_no: deviceNo.trim() }, { showErrorMessage: false })
|
||||
}
|
||||
|
||||
export function getScanRecords(params: Record<string, any> = {}) {
|
||||
return request.get<{ items: ProduceScanRecord[]; total: number }>('/produce/scan-data', {
|
||||
limit: 50,
|
||||
page: 1,
|
||||
...params,
|
||||
}, { showErrorMessage: false })
|
||||
}
|
||||
|
||||
export function getMaterialCategories() {
|
||||
return request.get<Array<{ id: string | number; category_name: string }>>('/produce/scanapp/material-category', {}, { showErrorMessage: false })
|
||||
}
|
||||
|
||||
export function getFaultDescriptionOptions(categoryIds: Array<string | number>) {
|
||||
return request.get<Array<{ id: string | number; category_id: string | number; description: string }>>(
|
||||
'/produce/hardware-fault-description/options',
|
||||
{ category_ids: categoryIds.join(',') },
|
||||
{ showErrorMessage: false },
|
||||
)
|
||||
}
|
||||
|
||||
export function lookupMaterial(productCode: string) {
|
||||
return request.get<{
|
||||
id: string | number
|
||||
category_id: string | number
|
||||
category_name: string
|
||||
product_code: string
|
||||
product_name: string
|
||||
}>('/produce/material/lookup', { product_code: productCode.trim() }, { showErrorMessage: false })
|
||||
}
|
||||
|
||||
export function submitRepairRecord(data: Record<string, any>) {
|
||||
return request.post<{ code: number; msg: string; data?: { id: string | number; repair_no: string } }>('/produce/repair-record/record', data, {
|
||||
showErrorMessage: false,
|
||||
returnFullResponse: true,
|
||||
})
|
||||
}
|
||||
|
||||
export async function uploadProduceImage(file: File, subDir: 'exception' | 'repair') {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
formData.append('sub_dir', subDir)
|
||||
const result = await request.upload<Record<string, any>>('/file/image', formData)
|
||||
return result?.url || result?.full_url || result?.path || ''
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import request from '~/utils/request'
|
||||
|
||||
export type ProduceQueryRecordType = 'scan' | 'product' | 'exception' | 'repair'
|
||||
|
||||
export interface ProduceQueryRecord {
|
||||
id: string
|
||||
record_type: ProduceQueryRecordType
|
||||
record_type_name: string
|
||||
order_no?: string
|
||||
product_code?: string
|
||||
process_name?: string
|
||||
step_name?: string
|
||||
status_name?: string
|
||||
result_name?: string
|
||||
operator?: string
|
||||
user_code?: string
|
||||
quantity?: number | null
|
||||
event_at?: number
|
||||
event_date?: string | null
|
||||
remark?: string
|
||||
// 异常提报和维修记录详情
|
||||
exception_reason?: string
|
||||
exception_content?: string
|
||||
material_category?: string
|
||||
fault_description?: string
|
||||
}
|
||||
|
||||
export interface ProduceQueryPage {
|
||||
items: ProduceQueryRecord[]
|
||||
total: number
|
||||
page: number
|
||||
limit: number
|
||||
}
|
||||
|
||||
export interface ProduceQueryParams {
|
||||
record_type: ProduceQueryRecordType
|
||||
order_no?: string
|
||||
keyword?: string
|
||||
user_code?: string
|
||||
status?: string | number
|
||||
scan_result?: string | number
|
||||
start_date?: string
|
||||
end_date?: string
|
||||
page?: number
|
||||
limit?: number
|
||||
}
|
||||
|
||||
export interface ProduceQueryTimeline {
|
||||
order_no: string
|
||||
total: number
|
||||
items: ProduceQueryRecord[]
|
||||
}
|
||||
|
||||
export function queryProduceRecords(params: ProduceQueryParams) {
|
||||
return request.get<ProduceQueryPage>('/produce/query/records', params, { showErrorMessage: false })
|
||||
}
|
||||
|
||||
export function queryProduceTimeline(orderNo: string) {
|
||||
return request.get<ProduceQueryTimeline>('/produce/query/timeline', { order_no: orderNo.trim() }, { showErrorMessage: false })
|
||||
}
|
||||
@@ -0,0 +1,549 @@
|
||||
<template>
|
||||
<div class="entry-mode-panel">
|
||||
<div class="main-top-grid">
|
||||
<section class="workbench-panel entry-column-panel">
|
||||
<div class="panel-header column-header">
|
||||
<div>
|
||||
<div class="panel-title">整机扫码输入</div>
|
||||
<div class="panel-subtitle">当前工序:{{ processLabel || '未选择' }} / {{ stepLabel || '未选择' }}</div>
|
||||
</div>
|
||||
<div class="panel-metric-card">
|
||||
<span class="panel-metric-label">总数</span>
|
||||
<strong class="panel-metric-value">{{ hideProcessTotal ? '***' : statistics.today_total }}</strong>
|
||||
<button type="button" class="metric-eye-button" :class="{ 'is-hidden': hideProcessTotal }" @click="hideProcessTotal = !hideProcessTotal">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M12 5C6.5 5 2.12 8.44 1 12c1.12 3.56 5.5 7 11 7s9.88-3.44 11-7c-1.12-3.56-5.5-7-11-7Zm0 11.5A4.5 4.5 0 1 1 12 7.5a4.5 4.5 0 0 1 0 9Zm0-2.5A2 2 0 1 0 12 10a2 2 0 0 0 0 4Z" fill="currentColor" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="workbench-form">
|
||||
<div class="field-group field-primary">
|
||||
<label>加工单号</label>
|
||||
<el-input
|
||||
ref="processOrderNoInputRef"
|
||||
v-model="processOrderNo"
|
||||
clearable
|
||||
size="large"
|
||||
placeholder="请扫描或输入加工单号"
|
||||
@keyup.enter="handleProcessOrderNoComplete"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="field-row field-row-split">
|
||||
<div class="field-group">
|
||||
<label>商品编码</label>
|
||||
<el-input
|
||||
ref="productCodeInputRef"
|
||||
v-model="productCode"
|
||||
clearable
|
||||
size="large"
|
||||
placeholder="请扫描或输入商品编码"
|
||||
@keyup.enter="handleProductCodeComplete"
|
||||
/>
|
||||
</div>
|
||||
<div class="field-group field-group-quantity">
|
||||
<label>数量</label>
|
||||
<el-input-number
|
||||
ref="quantityInputRef"
|
||||
v-model="quantity"
|
||||
:min="1"
|
||||
:step="1"
|
||||
size="large"
|
||||
controls-position="right"
|
||||
class="quantity-input"
|
||||
@keyup.enter="handleQuantityComplete"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field-row">
|
||||
<div class="field-group">
|
||||
<div class="field-label-row">
|
||||
<label>用户编码</label>
|
||||
<div class="lock-switch">
|
||||
<span>锁定用户编码</span>
|
||||
<el-switch v-model="autoUserCode" />
|
||||
</div>
|
||||
</div>
|
||||
<el-input
|
||||
ref="userCodeInputRef"
|
||||
v-model="userCode"
|
||||
:disabled="autoUserCode"
|
||||
clearable
|
||||
size="large"
|
||||
placeholder="请扫描或输入用户编码"
|
||||
@update:model-value="userCode = String($event || '').toUpperCase()"
|
||||
@input="handleUserCodeInput"
|
||||
@keyup.enter="handleUserCodeComplete"
|
||||
@blur="runUserCodeCheck(userCode, true)"
|
||||
/>
|
||||
<div v-if="userCodeCheckError" class="field-error-tip">{{ userCodeCheckError }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="action-row">
|
||||
<el-button type="primary" size="large" :loading="submitting" @click="submitEntry">提交</el-button>
|
||||
<el-button size="large" @click="resetForm">重置输入</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="workbench-panel stats-column-panel">
|
||||
<div class="dashboard-header column-header">
|
||||
<div class="dashboard-title">工位统计</div>
|
||||
<span class="dashboard-caption">当日扫码量</span>
|
||||
</div>
|
||||
<div v-if="userStats.length" class="station-grid">
|
||||
<div v-for="item in userStats" :key="item.user_code" class="station-card">
|
||||
<div class="station-icon">工</div>
|
||||
<div class="station-info">
|
||||
<strong>{{ item.user_code }}</strong>
|
||||
</div>
|
||||
<b>{{ item.count }}</b>
|
||||
</div>
|
||||
</div>
|
||||
<el-empty v-else description="暂无数据" :image-size="72" />
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section class="records-panel">
|
||||
<div class="dashboard-header records-header">
|
||||
<div class="dashboard-title">最近记录</div>
|
||||
<span class="dashboard-caption">实时刷新最近 10 条</span>
|
||||
</div>
|
||||
<el-table :data="recentRecords" stripe>
|
||||
<el-table-column prop="process_order_no" label="加工单号" min-width="180" />
|
||||
<el-table-column prop="product_code" label="商品编码" min-width="180" />
|
||||
<el-table-column prop="quantity" label="数量" width="100" />
|
||||
<el-table-column prop="user_code" label="用户编码" min-width="120" />
|
||||
<el-table-column label="工序" min-width="140">
|
||||
<template #default="scope">{{ scope.row.process?.process_name || '-' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="步骤" min-width="140">
|
||||
<template #default="scope">{{ scope.row.step?.step_name || '-' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" min-width="120">
|
||||
<template #default="scope">{{ scope.row.status?.status_name || '-' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="结果" width="100">
|
||||
<template #default="scope">
|
||||
<el-tag :type="Number(scope.row.scan_result) === 1 ? 'success' : 'danger'">
|
||||
{{ Number(scope.row.scan_result) === 1 ? '成功' : '失败' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="operator" label="操作人" min-width="120" />
|
||||
<el-table-column prop="created_date" label="扫码时间" min-width="180" />
|
||||
</el-table>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import {
|
||||
checkMemberUser,
|
||||
getDataEntryConfig,
|
||||
getProductRecentRecords,
|
||||
getProductStatistics,
|
||||
getProductUserCodeStats,
|
||||
submitProductRecord,
|
||||
type ProduceRecordItem,
|
||||
type ProduceDataEntrySpecificRule,
|
||||
type ProduceStatistics,
|
||||
type ProduceUserStatsItem,
|
||||
} from '~/apps/produce/api/data-entry'
|
||||
|
||||
interface Props {
|
||||
selectedProcess?: string | number
|
||||
selectedStep?: string | number
|
||||
processLabel?: string
|
||||
stepLabel?: string
|
||||
scanMode?: 'manual' | 'scanner'
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
selectedProcess: '',
|
||||
selectedStep: '',
|
||||
processLabel: '',
|
||||
stepLabel: '',
|
||||
scanMode: 'manual',
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
'play-success': []
|
||||
'play-error': []
|
||||
'speak-success': []
|
||||
'speak-error': []
|
||||
}>()
|
||||
|
||||
const STORAGE_KEY_LOCK = 'produce_web_machine_entry_lock'
|
||||
const STORAGE_KEY_USER_CODE = 'produce_web_machine_entry_user_code'
|
||||
const STORAGE_KEY_HIDE_PROCESS_TOTAL = 'produce_web_machine_entry_hide_process_total'
|
||||
|
||||
const processOrderNo = ref('')
|
||||
const productCode = ref('')
|
||||
const quantity = ref(1)
|
||||
const userCode = ref('')
|
||||
const autoUserCode = ref(false)
|
||||
const hideProcessTotal = ref(false)
|
||||
const submitting = ref(false)
|
||||
const recentRecords = ref<ProduceRecordItem[]>([])
|
||||
const userStats = ref<ProduceUserStatsItem[]>([])
|
||||
const statistics = ref<ProduceStatistics>({ today_total: 0, success_total: 0, failed_total: 0 })
|
||||
const basicSettings = ref<Record<string, any>>({})
|
||||
const specificRules = ref<ProduceDataEntrySpecificRule[]>([])
|
||||
const checkingUserCode = ref(false)
|
||||
const userCodeCheckError = ref('')
|
||||
const processOrderNoInputRef = ref()
|
||||
const productCodeInputRef = ref()
|
||||
const quantityInputRef = ref()
|
||||
const userCodeInputRef = ref()
|
||||
let userCodeCheckTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let scannerSubmitTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let statisticsRequestToken = 0
|
||||
let recentRecordsRequestToken = 0
|
||||
let dataEntryConfigRequestToken = 0
|
||||
let initialized = false
|
||||
|
||||
const restoreLocalState = () => {
|
||||
if (typeof window === 'undefined') return
|
||||
autoUserCode.value = localStorage.getItem(STORAGE_KEY_LOCK) === 'true'
|
||||
hideProcessTotal.value = localStorage.getItem(STORAGE_KEY_HIDE_PROCESS_TOTAL) === 'true'
|
||||
if (autoUserCode.value) {
|
||||
userCode.value = localStorage.getItem(STORAGE_KEY_USER_CODE) || ''
|
||||
}
|
||||
}
|
||||
|
||||
const saveLocalState = () => {
|
||||
if (typeof window === 'undefined') return
|
||||
localStorage.setItem(STORAGE_KEY_LOCK, String(autoUserCode.value))
|
||||
localStorage.setItem(STORAGE_KEY_HIDE_PROCESS_TOTAL, String(hideProcessTotal.value))
|
||||
if (autoUserCode.value && userCode.value.trim()) {
|
||||
localStorage.setItem(STORAGE_KEY_USER_CODE, userCode.value)
|
||||
} else {
|
||||
localStorage.removeItem(STORAGE_KEY_USER_CODE)
|
||||
}
|
||||
}
|
||||
|
||||
const focusPrimaryInput = () => { setTimeout(() => processOrderNoInputRef.value?.focus?.(), 80) }
|
||||
const focusProductCodeInput = () => { setTimeout(() => productCodeInputRef.value?.focus?.(), 80) }
|
||||
const focusQuantityInput = () => { setTimeout(() => quantityInputRef.value?.focus?.(), 80) }
|
||||
const focusUserCodeInput = () => { setTimeout(() => userCodeInputRef.value?.focus?.(), 80) }
|
||||
|
||||
const clearScannerSubmitTimer = () => {
|
||||
if (!scannerSubmitTimer) return
|
||||
clearTimeout(scannerSubmitTimer)
|
||||
scannerSubmitTimer = null
|
||||
}
|
||||
|
||||
const handleProcessOrderNoComplete = () => {
|
||||
if (props.scanMode !== 'scanner') {
|
||||
void submitEntry()
|
||||
return
|
||||
}
|
||||
if (!submitting.value) focusProductCodeInput()
|
||||
}
|
||||
|
||||
const handleProductCodeComplete = () => {
|
||||
if (props.scanMode !== 'scanner') {
|
||||
void submitEntry()
|
||||
return
|
||||
}
|
||||
if (!submitting.value) focusQuantityInput()
|
||||
}
|
||||
|
||||
const handleQuantityComplete = () => {
|
||||
if (props.scanMode !== 'scanner') return
|
||||
if (submitting.value) return
|
||||
if (autoUserCode.value) {
|
||||
void submitEntry()
|
||||
return
|
||||
}
|
||||
focusUserCodeInput()
|
||||
}
|
||||
|
||||
const handleUserCodeComplete = () => {
|
||||
if (props.scanMode !== 'scanner') {
|
||||
void submitEntry()
|
||||
return
|
||||
}
|
||||
if (submitting.value) return
|
||||
clearScannerSubmitTimer()
|
||||
userCodeInputRef.value?.blur?.()
|
||||
scannerSubmitTimer = setTimeout(() => {
|
||||
scannerSubmitTimer = null
|
||||
if (!submitting.value) void submitEntry()
|
||||
}, 50)
|
||||
}
|
||||
|
||||
const handleUserCodeInput = () => {
|
||||
if (props.scanMode !== 'scanner' || !userCode.value.trim() || submitting.value) return
|
||||
clearScannerSubmitTimer()
|
||||
scannerSubmitTimer = setTimeout(() => {
|
||||
scannerSubmitTimer = null
|
||||
handleUserCodeComplete()
|
||||
}, 300)
|
||||
}
|
||||
|
||||
const resetDashboardData = () => {
|
||||
recentRecords.value = []
|
||||
userStats.value = []
|
||||
statistics.value = { today_total: 0, success_total: 0, failed_total: 0 }
|
||||
}
|
||||
|
||||
const isSameSelection = (processId: string | number, stepId: string | number) => String(props.selectedProcess) === String(processId) && String(props.selectedStep) === String(stepId)
|
||||
const resetUserCodeCheckState = () => { userCodeCheckError.value = '' }
|
||||
|
||||
const runUserCodeCheck = async (value: string, silent: boolean = false) => {
|
||||
const normalizedValue = value.trim()
|
||||
if (!normalizedValue) {
|
||||
checkingUserCode.value = false
|
||||
resetUserCodeCheckState()
|
||||
return false
|
||||
}
|
||||
checkingUserCode.value = true
|
||||
try {
|
||||
await checkMemberUser(normalizedValue)
|
||||
userCodeCheckError.value = ''
|
||||
return true
|
||||
} catch (error: any) {
|
||||
userCodeCheckError.value = error?.message || '用户编码不存在'
|
||||
if (!silent) ElMessage.warning(userCodeCheckError.value)
|
||||
return false
|
||||
} finally {
|
||||
checkingUserCode.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const scheduleUserCodeCheck = (value: string) => {
|
||||
if (userCodeCheckTimer) clearTimeout(userCodeCheckTimer)
|
||||
const normalizedValue = value.trim()
|
||||
if (!normalizedValue) {
|
||||
resetUserCodeCheckState()
|
||||
checkingUserCode.value = false
|
||||
return
|
||||
}
|
||||
userCodeCheckTimer = setTimeout(() => { void runUserCodeCheck(normalizedValue, true) }, 360)
|
||||
}
|
||||
|
||||
const loadStatisticsData = async () => {
|
||||
if (!props.selectedProcess || !props.selectedStep) {
|
||||
resetDashboardData()
|
||||
return
|
||||
}
|
||||
const requestToken = ++statisticsRequestToken
|
||||
const processSnapshot = props.selectedProcess
|
||||
const stepSnapshot = props.selectedStep
|
||||
const params = { process_id: processSnapshot, step_id: stepSnapshot }
|
||||
const [statsRes, userStatsRes] = await Promise.all([
|
||||
getProductStatistics(params).catch(() => ({ today_total: 0, success_total: 0, failed_total: 0 })),
|
||||
getProductUserCodeStats(params).catch(() => []),
|
||||
])
|
||||
if (requestToken !== statisticsRequestToken || !isSameSelection(processSnapshot, stepSnapshot)) return
|
||||
statistics.value = {
|
||||
today_total: Number(statsRes?.today_total || 0),
|
||||
success_total: Number(statsRes?.success_total || 0),
|
||||
failed_total: Number(statsRes?.failed_total || 0),
|
||||
}
|
||||
userStats.value = Array.isArray(userStatsRes) ? userStatsRes : []
|
||||
}
|
||||
|
||||
const loadRecentData = async () => {
|
||||
if (!props.selectedProcess || !props.selectedStep) {
|
||||
recentRecords.value = []
|
||||
return
|
||||
}
|
||||
const requestToken = ++recentRecordsRequestToken
|
||||
const processSnapshot = props.selectedProcess
|
||||
const stepSnapshot = props.selectedStep
|
||||
const res = await getProductRecentRecords({ process_id: processSnapshot, step_id: stepSnapshot, limit: 10 }).catch(() => ({ items: [] }))
|
||||
if (requestToken !== recentRecordsRequestToken || !isSameSelection(processSnapshot, stepSnapshot)) return
|
||||
recentRecords.value = Array.isArray(res?.items) ? res.items : []
|
||||
}
|
||||
|
||||
const loadDataEntryConfig = async () => {
|
||||
const requestToken = ++dataEntryConfigRequestToken
|
||||
const processSnapshot = props.selectedProcess
|
||||
const stepSnapshot = props.selectedStep
|
||||
const processOrderNoSnapshot = processOrderNo.value.trim()
|
||||
const productCodeSnapshot = productCode.value.trim()
|
||||
const userCodeSnapshot = userCode.value.trim()
|
||||
const res = await getDataEntryConfig({
|
||||
process_id: processSnapshot,
|
||||
step_id: stepSnapshot,
|
||||
apply_mode: 'machine',
|
||||
process_order_no: processOrderNoSnapshot,
|
||||
product_code: productCodeSnapshot,
|
||||
user_code: userCodeSnapshot,
|
||||
order_no: processOrderNoSnapshot,
|
||||
}).catch(() => null)
|
||||
if (requestToken !== dataEntryConfigRequestToken || !isSameSelection(processSnapshot, stepSnapshot) || processOrderNo.value.trim() !== processOrderNoSnapshot || productCode.value.trim() !== productCodeSnapshot || userCode.value.trim() !== userCodeSnapshot) return
|
||||
basicSettings.value = res?.basic_setting || {}
|
||||
specificRules.value = Array.isArray(res?.specific_rules) ? res.specific_rules : []
|
||||
}
|
||||
|
||||
const matchSpecificRule = (rule: ProduceDataEntrySpecificRule, valueMap: Record<string, string>) => {
|
||||
const matchField = rule.match_field || 'order_no'
|
||||
const matchValue = String(valueMap[matchField] || '').trim()
|
||||
const pattern = String(rule.rule_pattern || '').trim()
|
||||
if (!matchValue || !pattern) {
|
||||
return false
|
||||
}
|
||||
try {
|
||||
return new RegExp(pattern).test(matchValue)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
const validateSpecificRules = () => {
|
||||
if (!specificRules.value.length) {
|
||||
return true
|
||||
}
|
||||
|
||||
const context = {
|
||||
process_order_no: processOrderNo.value.trim(),
|
||||
product_code: productCode.value.trim(),
|
||||
user_code: userCode.value.trim(),
|
||||
}
|
||||
|
||||
const blockedRule = specificRules.value.find((rule: ProduceDataEntrySpecificRule) => {
|
||||
if ((rule.apply_mode || 'order') !== 'machine' && rule.apply_mode !== 'both') {
|
||||
return false
|
||||
}
|
||||
return Number(rule.rule_type) === 2 && matchSpecificRule(rule, context)
|
||||
})
|
||||
|
||||
if (blockedRule) {
|
||||
ElMessage.warning(blockedRule.rule_name || '当前整机数据命中特定规则,禁止提交')
|
||||
const matchField = blockedRule.match_field || 'process_order_no'
|
||||
if (matchField === 'product_code') {
|
||||
focusProductCodeInput()
|
||||
} else if (matchField === 'user_code') {
|
||||
focusUserCodeInput()
|
||||
} else {
|
||||
focusPrimaryInput()
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
const validateBeforeSubmit = () => {
|
||||
if (!props.selectedProcess) { ElMessage.warning('请选择工序'); return false }
|
||||
if (!props.selectedStep) { ElMessage.warning('请选择步骤'); return false }
|
||||
if (!processOrderNo.value.trim()) { ElMessage.warning('请输入加工单号'); focusPrimaryInput(); return false }
|
||||
const processOrderNoRegex = String(basicSettings.value.process_order_no_regex || '').trim()
|
||||
if (processOrderNoRegex) {
|
||||
try {
|
||||
const regex = new RegExp(processOrderNoRegex)
|
||||
if (!regex.test(processOrderNo.value.trim())) { ElMessage.warning('加工单号格式不符合生产配置规则'); focusPrimaryInput(); return false }
|
||||
} catch {}
|
||||
}
|
||||
if (!productCode.value.trim()) { ElMessage.warning('请输入商品编码'); focusProductCodeInput(); return false }
|
||||
const productCodeMinLength = Number(basicSettings.value.product_code_min_length || 0)
|
||||
if (productCodeMinLength > 0 && productCode.value.trim().length < productCodeMinLength) { ElMessage.warning(`商品编码长度不能少于 ${productCodeMinLength} 位`); focusProductCodeInput(); return false }
|
||||
const productCodeMaxLength = Number(basicSettings.value.product_code_max_length || 0)
|
||||
if (productCodeMaxLength > 0 && productCode.value.trim().length > productCodeMaxLength) { ElMessage.warning(`商品编码长度不能超过 ${productCodeMaxLength} 位`); focusProductCodeInput(); return false }
|
||||
const productCodeRegex = String(basicSettings.value.product_code_regex || '').trim()
|
||||
if (productCodeRegex) {
|
||||
try {
|
||||
const regex = new RegExp(productCodeRegex)
|
||||
if (!regex.test(productCode.value.trim())) { ElMessage.warning('商品编码格式不符合生产配置规则'); focusProductCodeInput(); return false }
|
||||
} catch {}
|
||||
}
|
||||
if (!Number.isFinite(Number(quantity.value)) || Number(quantity.value) < 1) { ElMessage.warning('数量必须大于 0'); focusQuantityInput(); return false }
|
||||
if (!userCode.value.trim()) { ElMessage.warning('请输入用户编码'); focusUserCodeInput(); return false }
|
||||
const userCodeRegex = String(basicSettings.value.user_code_regex || '').trim()
|
||||
if (userCodeRegex) {
|
||||
try {
|
||||
const regex = new RegExp(userCodeRegex)
|
||||
if (!regex.test(userCode.value.trim())) { ElMessage.warning('用户编码格式不符合基础设置规则'); focusUserCodeInput(); return false }
|
||||
} catch {}
|
||||
}
|
||||
if (userCodeCheckError.value) { ElMessage.warning(userCodeCheckError.value); focusUserCodeInput(); return false }
|
||||
if (!validateSpecificRules()) { return false }
|
||||
return true
|
||||
}
|
||||
|
||||
const resetForm = () => {
|
||||
processOrderNo.value = ''
|
||||
productCode.value = ''
|
||||
quantity.value = 1
|
||||
if (!autoUserCode.value) {
|
||||
userCode.value = ''
|
||||
resetUserCodeCheckState()
|
||||
}
|
||||
saveLocalState()
|
||||
focusPrimaryInput()
|
||||
}
|
||||
|
||||
const submitEntry = async () => {
|
||||
if (!validateBeforeSubmit()) return
|
||||
const passed = await runUserCodeCheck(userCode.value)
|
||||
if (!passed) { emit('play-error'); nextTick(() => focusUserCodeInput()); return }
|
||||
submitting.value = true
|
||||
try {
|
||||
const result = await submitProductRecord({
|
||||
process_order_no: processOrderNo.value.trim(),
|
||||
product_code: productCode.value.trim(),
|
||||
quantity: Number(quantity.value) || 1,
|
||||
process_id: props.selectedProcess,
|
||||
step_id: props.selectedStep,
|
||||
user_code: userCode.value.trim(),
|
||||
scan_result: 1,
|
||||
})
|
||||
if (Number(result?.code) !== 0) throw new Error(result?.msg || '录入失败')
|
||||
ElMessage.success('录入成功')
|
||||
emit('play-success')
|
||||
emit('speak-success')
|
||||
saveLocalState()
|
||||
resetForm()
|
||||
await Promise.all([loadStatisticsData(), loadRecentData()])
|
||||
} catch (error: any) {
|
||||
ElMessage.error(error?.message || '录入失败')
|
||||
emit('play-error')
|
||||
emit('speak-error')
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const refreshBySelection = async () => {
|
||||
if (!props.selectedProcess || !props.selectedStep) {
|
||||
basicSettings.value = {}
|
||||
specificRules.value = []
|
||||
resetDashboardData()
|
||||
return
|
||||
}
|
||||
resetDashboardData()
|
||||
await Promise.all([loadStatisticsData(), loadRecentData(), loadDataEntryConfig()])
|
||||
focusPrimaryInput()
|
||||
}
|
||||
|
||||
watch(() => [props.selectedProcess, props.selectedStep], () => { if (initialized) void refreshBySelection() })
|
||||
watch(processOrderNo, () => { if (initialized) { saveLocalState(); void loadDataEntryConfig() } })
|
||||
watch([productCode, quantity], () => { if (initialized) { saveLocalState(); void loadDataEntryConfig() } })
|
||||
watch(userCode, () => { if (initialized) { scheduleUserCodeCheck(userCode.value); if (autoUserCode.value || userCode.value.trim()) saveLocalState(); void loadDataEntryConfig() } })
|
||||
watch([autoUserCode, hideProcessTotal], () => { if (initialized) saveLocalState() })
|
||||
|
||||
onMounted(async () => {
|
||||
restoreLocalState()
|
||||
await refreshBySelection()
|
||||
initialized = true
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (userCodeCheckTimer) clearTimeout(userCodeCheckTimer)
|
||||
clearScannerSubmitTimer()
|
||||
})
|
||||
|
||||
defineExpose({ focusPrimaryInput, focusProductCodeInput, focusQuantityInput, focusUserCodeInput, submitEntry, resetForm })
|
||||
</script>
|
||||
|
||||
<style scoped src="./entry-panel.css"></style>
|
||||
@@ -0,0 +1,610 @@
|
||||
<template>
|
||||
<div class="entry-mode-panel">
|
||||
<div class="main-top-grid">
|
||||
<section class="workbench-panel entry-column-panel">
|
||||
<div class="panel-header column-header">
|
||||
<div>
|
||||
<div class="panel-title">订单扫码输入</div>
|
||||
<div class="panel-subtitle">当前工序:{{ processLabel || '未选择' }} / {{ stepLabel || '未选择' }}</div>
|
||||
</div>
|
||||
<div class="panel-metric-card">
|
||||
<span class="panel-metric-label">总数</span>
|
||||
<strong class="panel-metric-value">{{ hideProcessTotal ? '***' : statistics.today_total }}</strong>
|
||||
<button type="button" class="metric-eye-button" :class="{ 'is-hidden': hideProcessTotal }" @click="hideProcessTotal = !hideProcessTotal">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M12 5C6.5 5 2.12 8.44 1 12c1.12 3.56 5.5 7 11 7s9.88-3.44 11-7c-1.12-3.56-5.5-7-11-7Zm0 11.5A4.5 4.5 0 1 1 12 7.5a4.5 4.5 0 0 1 0 9Zm0-2.5A2 2 0 1 0 12 10a2 2 0 0 0 0 4Z" fill="currentColor" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="workbench-form">
|
||||
<div class="field-group field-primary">
|
||||
<label>订单号</label>
|
||||
<el-input
|
||||
ref="orderNoInputRef"
|
||||
v-model="orderNo"
|
||||
clearable
|
||||
size="large"
|
||||
placeholder="请扫描或输入订单号"
|
||||
@keyup.enter="handleOrderNoComplete"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="field-row">
|
||||
<div class="field-group">
|
||||
<div class="field-label-row">
|
||||
<label>用户编码</label>
|
||||
<div class="lock-switch">
|
||||
<span>锁定用户编码</span>
|
||||
<el-switch v-model="autoUserCode" />
|
||||
</div>
|
||||
</div>
|
||||
<el-input
|
||||
ref="userCodeInputRef"
|
||||
v-model="userCode"
|
||||
:disabled="autoUserCode"
|
||||
clearable
|
||||
size="large"
|
||||
placeholder="请扫描或输入用户编码"
|
||||
@update:model-value="userCode = String($event || '').toUpperCase()"
|
||||
@input="handleUserCodeInput"
|
||||
@keyup.enter="handleUserCodeComplete"
|
||||
@blur="runUserCodeCheck(userCode, true)"
|
||||
/>
|
||||
<div v-if="userCodeCheckError" class="field-error-tip">{{ userCodeCheckError }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="action-row">
|
||||
<el-button type="primary" size="large" :loading="submitting" @click="submitEntry">提交</el-button>
|
||||
<el-button size="large" @click="resetForm">重置输入</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="workbench-panel stats-column-panel">
|
||||
<div class="dashboard-header column-header">
|
||||
<div class="dashboard-title">工位统计</div>
|
||||
<span class="dashboard-caption">当日扫码量</span>
|
||||
</div>
|
||||
<div v-if="userStats.length" class="station-grid">
|
||||
<div v-for="item in userStats" :key="item.user_code" class="station-card">
|
||||
<div class="station-icon">工</div>
|
||||
<div class="station-info">
|
||||
<strong>{{ item.user_code }}</strong>
|
||||
</div>
|
||||
<b>{{ item.count }}</b>
|
||||
</div>
|
||||
</div>
|
||||
<el-empty v-else description="暂无数据" :image-size="72" />
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section class="records-panel">
|
||||
<div class="dashboard-header records-header">
|
||||
<div class="dashboard-title">最近记录</div>
|
||||
<span class="dashboard-caption">实时刷新最近 10 条</span>
|
||||
</div>
|
||||
<el-table :data="recentRecords" stripe>
|
||||
<el-table-column prop="order_no" label="订单号" min-width="180" />
|
||||
<el-table-column prop="user_code" label="用户编码" min-width="120" />
|
||||
<el-table-column label="工序" min-width="140">
|
||||
<template #default="scope">{{ scope.row.process?.process_name || '-' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="步骤" min-width="140">
|
||||
<template #default="scope">{{ scope.row.step?.step_name || '-' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" min-width="120">
|
||||
<template #default="scope">{{ scope.row.status?.status_name || '-' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="结果" width="100">
|
||||
<template #default="scope">
|
||||
<el-tag :type="Number(scope.row.scan_result) === 1 ? 'success' : 'danger'">
|
||||
{{ Number(scope.row.scan_result) === 1 ? '成功' : '失败' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="operator" label="操作人" min-width="120" />
|
||||
<el-table-column prop="scan_date" label="扫码时间" min-width="180" />
|
||||
</el-table>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import {
|
||||
checkMemberUser,
|
||||
getDataEntryConfig,
|
||||
getRecentRecords,
|
||||
getStatistics,
|
||||
getUserCodeStats,
|
||||
submitScanData,
|
||||
type ProduceRecordItem,
|
||||
type ProduceDataEntrySpecificRule,
|
||||
type ProduceStatistics,
|
||||
type ProduceUserStatsItem,
|
||||
} from '~/apps/produce/api/data-entry'
|
||||
|
||||
interface Props {
|
||||
selectedProcess?: string | number
|
||||
selectedStep?: string | number
|
||||
processLabel?: string
|
||||
stepLabel?: string
|
||||
scanMode?: 'manual' | 'scanner'
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
selectedProcess: '',
|
||||
selectedStep: '',
|
||||
processLabel: '',
|
||||
stepLabel: '',
|
||||
scanMode: 'manual',
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
'play-success': []
|
||||
'play-error': []
|
||||
'speak-success': []
|
||||
'speak-error': []
|
||||
}>()
|
||||
|
||||
const STORAGE_KEY_LOCK = 'produce_web_order_entry_lock'
|
||||
const STORAGE_KEY_USER_CODE = 'produce_web_order_entry_user_code'
|
||||
const STORAGE_KEY_HIDE_PROCESS_TOTAL = 'produce_web_order_entry_hide_process_total'
|
||||
|
||||
const orderNo = ref('')
|
||||
const userCode = ref('')
|
||||
const autoUserCode = ref(false)
|
||||
const hideProcessTotal = ref(false)
|
||||
const submitting = ref(false)
|
||||
const recentRecords = ref<ProduceRecordItem[]>([])
|
||||
const userStats = ref<ProduceUserStatsItem[]>([])
|
||||
const statistics = ref<ProduceStatistics>({
|
||||
today_total: 0,
|
||||
success_total: 0,
|
||||
failed_total: 0,
|
||||
})
|
||||
const basicSettings = ref<Record<string, any>>({})
|
||||
const specificRules = ref<ProduceDataEntrySpecificRule[]>([])
|
||||
const checkingUserCode = ref(false)
|
||||
const userCodeCheckError = ref('')
|
||||
const orderNoInputRef = ref()
|
||||
const userCodeInputRef = ref()
|
||||
let userCodeCheckTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let scannerSubmitTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let statisticsRequestToken = 0
|
||||
let recentRecordsRequestToken = 0
|
||||
let dataEntryConfigRequestToken = 0
|
||||
let initialized = false
|
||||
|
||||
const restoreLocalState = () => {
|
||||
if (typeof window === 'undefined') return
|
||||
autoUserCode.value = localStorage.getItem(STORAGE_KEY_LOCK) === 'true'
|
||||
hideProcessTotal.value = localStorage.getItem(STORAGE_KEY_HIDE_PROCESS_TOTAL) === 'true'
|
||||
if (autoUserCode.value) {
|
||||
userCode.value = localStorage.getItem(STORAGE_KEY_USER_CODE) || ''
|
||||
}
|
||||
}
|
||||
|
||||
const saveLocalState = () => {
|
||||
if (typeof window === 'undefined') return
|
||||
localStorage.setItem(STORAGE_KEY_LOCK, String(autoUserCode.value))
|
||||
localStorage.setItem(STORAGE_KEY_HIDE_PROCESS_TOTAL, String(hideProcessTotal.value))
|
||||
if (autoUserCode.value && userCode.value.trim()) {
|
||||
localStorage.setItem(STORAGE_KEY_USER_CODE, userCode.value)
|
||||
} else {
|
||||
localStorage.removeItem(STORAGE_KEY_USER_CODE)
|
||||
}
|
||||
}
|
||||
|
||||
const focusPrimaryInput = () => {
|
||||
setTimeout(() => orderNoInputRef.value?.focus?.(), 80)
|
||||
}
|
||||
|
||||
const focusUserCodeInput = () => {
|
||||
setTimeout(() => userCodeInputRef.value?.focus?.(), 80)
|
||||
}
|
||||
|
||||
const clearScannerSubmitTimer = () => {
|
||||
if (!scannerSubmitTimer) {
|
||||
return
|
||||
}
|
||||
clearTimeout(scannerSubmitTimer)
|
||||
scannerSubmitTimer = null
|
||||
}
|
||||
|
||||
const handleOrderNoComplete = () => {
|
||||
if (props.scanMode !== 'scanner') {
|
||||
void submitEntry()
|
||||
return
|
||||
}
|
||||
if (submitting.value) {
|
||||
return
|
||||
}
|
||||
if (autoUserCode.value) {
|
||||
void submitEntry()
|
||||
return
|
||||
}
|
||||
focusUserCodeInput()
|
||||
}
|
||||
|
||||
const handleUserCodeComplete = () => {
|
||||
if (props.scanMode !== 'scanner') {
|
||||
void submitEntry()
|
||||
return
|
||||
}
|
||||
if (submitting.value) {
|
||||
return
|
||||
}
|
||||
clearScannerSubmitTimer()
|
||||
userCodeInputRef.value?.blur?.()
|
||||
scannerSubmitTimer = setTimeout(() => {
|
||||
scannerSubmitTimer = null
|
||||
if (!submitting.value) {
|
||||
void submitEntry()
|
||||
}
|
||||
}, 50)
|
||||
}
|
||||
|
||||
const handleUserCodeInput = () => {
|
||||
if (props.scanMode !== 'scanner' || !userCode.value.trim() || submitting.value) {
|
||||
return
|
||||
}
|
||||
clearScannerSubmitTimer()
|
||||
scannerSubmitTimer = setTimeout(() => {
|
||||
scannerSubmitTimer = null
|
||||
handleUserCodeComplete()
|
||||
}, 300)
|
||||
}
|
||||
|
||||
const resetDashboardData = () => {
|
||||
recentRecords.value = []
|
||||
userStats.value = []
|
||||
statistics.value = {
|
||||
today_total: 0,
|
||||
success_total: 0,
|
||||
failed_total: 0,
|
||||
}
|
||||
}
|
||||
|
||||
const isSameSelection = (processId: string | number, stepId: string | number) => {
|
||||
return String(props.selectedProcess) === String(processId)
|
||||
&& String(props.selectedStep) === String(stepId)
|
||||
}
|
||||
|
||||
const resetUserCodeCheckState = () => {
|
||||
userCodeCheckError.value = ''
|
||||
}
|
||||
|
||||
const runUserCodeCheck = async (value: string, silent: boolean = false) => {
|
||||
const normalizedValue = value.trim()
|
||||
if (!normalizedValue) {
|
||||
checkingUserCode.value = false
|
||||
resetUserCodeCheckState()
|
||||
return false
|
||||
}
|
||||
|
||||
checkingUserCode.value = true
|
||||
try {
|
||||
await checkMemberUser(normalizedValue)
|
||||
userCodeCheckError.value = ''
|
||||
return true
|
||||
} catch (error: any) {
|
||||
userCodeCheckError.value = error?.message || '用户编码不存在'
|
||||
if (!silent) {
|
||||
ElMessage.warning(userCodeCheckError.value)
|
||||
}
|
||||
return false
|
||||
} finally {
|
||||
checkingUserCode.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const scheduleUserCodeCheck = (value: string) => {
|
||||
if (userCodeCheckTimer) {
|
||||
clearTimeout(userCodeCheckTimer)
|
||||
}
|
||||
|
||||
const normalizedValue = value.trim()
|
||||
if (!normalizedValue) {
|
||||
resetUserCodeCheckState()
|
||||
checkingUserCode.value = false
|
||||
return
|
||||
}
|
||||
|
||||
userCodeCheckTimer = setTimeout(() => {
|
||||
void runUserCodeCheck(normalizedValue, true)
|
||||
}, 360)
|
||||
}
|
||||
|
||||
const loadStatisticsData = async () => {
|
||||
if (!props.selectedProcess || !props.selectedStep) {
|
||||
resetDashboardData()
|
||||
return
|
||||
}
|
||||
|
||||
const requestToken = ++statisticsRequestToken
|
||||
const processSnapshot = props.selectedProcess
|
||||
const stepSnapshot = props.selectedStep
|
||||
const params = {
|
||||
process_id: processSnapshot,
|
||||
step_id: stepSnapshot,
|
||||
}
|
||||
|
||||
const [statsRes, userStatsRes] = await Promise.all([
|
||||
getStatistics(params).catch(() => ({ today_total: 0, success_total: 0, failed_total: 0 })),
|
||||
getUserCodeStats(params).catch(() => []),
|
||||
])
|
||||
|
||||
if (requestToken !== statisticsRequestToken || !isSameSelection(processSnapshot, stepSnapshot)) {
|
||||
return
|
||||
}
|
||||
|
||||
statistics.value = {
|
||||
today_total: Number(statsRes?.today_total || 0),
|
||||
success_total: Number(statsRes?.success_total || 0),
|
||||
failed_total: Number(statsRes?.failed_total || 0),
|
||||
}
|
||||
userStats.value = Array.isArray(userStatsRes) ? userStatsRes : []
|
||||
}
|
||||
|
||||
const loadRecentData = async () => {
|
||||
if (!props.selectedProcess || !props.selectedStep) {
|
||||
recentRecords.value = []
|
||||
return
|
||||
}
|
||||
|
||||
const requestToken = ++recentRecordsRequestToken
|
||||
const processSnapshot = props.selectedProcess
|
||||
const stepSnapshot = props.selectedStep
|
||||
const res = await getRecentRecords({
|
||||
process_id: processSnapshot,
|
||||
step_id: stepSnapshot,
|
||||
limit: 10,
|
||||
}).catch(() => ({ items: [] }))
|
||||
|
||||
if (requestToken !== recentRecordsRequestToken || !isSameSelection(processSnapshot, stepSnapshot)) {
|
||||
return
|
||||
}
|
||||
|
||||
recentRecords.value = Array.isArray(res?.items) ? res.items : []
|
||||
}
|
||||
|
||||
const loadDataEntryConfig = async () => {
|
||||
const requestToken = ++dataEntryConfigRequestToken
|
||||
const processSnapshot = props.selectedProcess
|
||||
const stepSnapshot = props.selectedStep
|
||||
const orderSnapshot = orderNo.value.trim()
|
||||
|
||||
const res = await getDataEntryConfig({
|
||||
process_id: processSnapshot,
|
||||
step_id: stepSnapshot,
|
||||
apply_mode: 'order',
|
||||
order_no: orderSnapshot,
|
||||
}).catch(() => null)
|
||||
|
||||
if (
|
||||
requestToken !== dataEntryConfigRequestToken
|
||||
|| !isSameSelection(processSnapshot, stepSnapshot)
|
||||
|| orderNo.value.trim() !== orderSnapshot
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
basicSettings.value = res?.basic_setting || {}
|
||||
specificRules.value = Array.isArray(res?.specific_rules) ? res.specific_rules : []
|
||||
}
|
||||
|
||||
const matchSpecificRule = (rule: ProduceDataEntrySpecificRule, valueMap: Record<string, string>) => {
|
||||
const matchField = rule.match_field || 'order_no'
|
||||
const matchValue = String(valueMap[matchField] || '').trim()
|
||||
const pattern = String(rule.rule_pattern || '').trim()
|
||||
if (!matchValue || !pattern) {
|
||||
return false
|
||||
}
|
||||
try {
|
||||
return new RegExp(pattern).test(matchValue)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
const validateSpecificRules = () => {
|
||||
if (!specificRules.value.length) {
|
||||
return true
|
||||
}
|
||||
|
||||
const context = {
|
||||
order_no: orderNo.value.trim(),
|
||||
user_code: userCode.value.trim(),
|
||||
}
|
||||
|
||||
const blockedRule = specificRules.value.find((rule: ProduceDataEntrySpecificRule) => {
|
||||
if ((rule.apply_mode || 'order') !== 'order' && rule.apply_mode !== 'both') {
|
||||
return false
|
||||
}
|
||||
return Number(rule.rule_type) === 2 && matchSpecificRule(rule, context)
|
||||
})
|
||||
|
||||
if (blockedRule) {
|
||||
ElMessage.warning(blockedRule.rule_name || '当前订单命中特定规则,禁止提交')
|
||||
focusPrimaryInput()
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
const validateBeforeSubmit = () => {
|
||||
if (!props.selectedProcess) {
|
||||
ElMessage.warning('请选择工序')
|
||||
return false
|
||||
}
|
||||
if (!props.selectedStep) {
|
||||
ElMessage.warning('请选择步骤')
|
||||
return false
|
||||
}
|
||||
if (!orderNo.value.trim()) {
|
||||
ElMessage.warning('请输入订单号')
|
||||
focusPrimaryInput()
|
||||
return false
|
||||
}
|
||||
const orderNoMinLength = Number(basicSettings.value.order_no_min_length || 0)
|
||||
if (orderNoMinLength > 0 && orderNo.value.trim().length < orderNoMinLength) {
|
||||
ElMessage.warning(`订单号长度不能少于 ${orderNoMinLength} 位`)
|
||||
focusPrimaryInput()
|
||||
return false
|
||||
}
|
||||
const orderNoRegex = String(basicSettings.value.order_no_regex || '').trim()
|
||||
if (orderNoRegex) {
|
||||
try {
|
||||
const regex = new RegExp(orderNoRegex)
|
||||
if (!regex.test(orderNo.value.trim())) {
|
||||
ElMessage.warning('订单号格式不符合基础设置规则')
|
||||
focusPrimaryInput()
|
||||
return false
|
||||
}
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
if (!userCode.value.trim()) {
|
||||
ElMessage.warning('请输入用户编码')
|
||||
focusUserCodeInput()
|
||||
return false
|
||||
}
|
||||
const userCodeRegex = String(basicSettings.value.user_code_regex || '').trim()
|
||||
if (userCodeRegex) {
|
||||
try {
|
||||
const regex = new RegExp(userCodeRegex)
|
||||
if (!regex.test(userCode.value.trim())) {
|
||||
ElMessage.warning('用户编码格式不符合基础设置规则')
|
||||
focusUserCodeInput()
|
||||
return false
|
||||
}
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
if (userCodeCheckError.value) {
|
||||
ElMessage.warning(userCodeCheckError.value)
|
||||
focusUserCodeInput()
|
||||
return false
|
||||
}
|
||||
if (!validateSpecificRules()) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
const resetForm = () => {
|
||||
orderNo.value = ''
|
||||
if (!autoUserCode.value) {
|
||||
userCode.value = ''
|
||||
resetUserCodeCheckState()
|
||||
}
|
||||
saveLocalState()
|
||||
focusPrimaryInput()
|
||||
}
|
||||
|
||||
const submitEntry = async () => {
|
||||
if (!validateBeforeSubmit()) return
|
||||
const passed = await runUserCodeCheck(userCode.value)
|
||||
if (!passed) {
|
||||
emit('play-error')
|
||||
nextTick(() => focusUserCodeInput())
|
||||
return
|
||||
}
|
||||
|
||||
submitting.value = true
|
||||
try {
|
||||
const result = await submitScanData({
|
||||
order_no: orderNo.value.trim(),
|
||||
process_id: props.selectedProcess,
|
||||
step_id: props.selectedStep,
|
||||
user_code: userCode.value.trim(),
|
||||
scan_result: 1,
|
||||
})
|
||||
|
||||
if (Number(result?.code) !== 0) {
|
||||
throw new Error(result?.msg || '录入失败')
|
||||
}
|
||||
|
||||
ElMessage.success('录入成功')
|
||||
emit('play-success')
|
||||
emit('speak-success')
|
||||
saveLocalState()
|
||||
resetForm()
|
||||
await Promise.all([loadStatisticsData(), loadRecentData()])
|
||||
} catch (error: any) {
|
||||
ElMessage.error(error?.message || '录入失败')
|
||||
emit('play-error')
|
||||
emit('speak-error')
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const refreshBySelection = async () => {
|
||||
if (!props.selectedProcess || !props.selectedStep) {
|
||||
basicSettings.value = {}
|
||||
specificRules.value = []
|
||||
resetDashboardData()
|
||||
return
|
||||
}
|
||||
|
||||
resetDashboardData()
|
||||
await Promise.all([loadStatisticsData(), loadRecentData(), loadDataEntryConfig()])
|
||||
focusPrimaryInput()
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [props.selectedProcess, props.selectedStep],
|
||||
() => {
|
||||
if (!initialized) return
|
||||
void refreshBySelection()
|
||||
}
|
||||
)
|
||||
|
||||
watch(orderNo, () => {
|
||||
if (!initialized) return
|
||||
saveLocalState()
|
||||
void loadDataEntryConfig()
|
||||
})
|
||||
|
||||
watch(userCode, () => {
|
||||
if (!initialized) return
|
||||
scheduleUserCodeCheck(userCode.value)
|
||||
if (autoUserCode.value || userCode.value.trim()) {
|
||||
saveLocalState()
|
||||
}
|
||||
})
|
||||
|
||||
watch([autoUserCode, hideProcessTotal], () => {
|
||||
if (!initialized) return
|
||||
saveLocalState()
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
restoreLocalState()
|
||||
await refreshBySelection()
|
||||
initialized = true
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (userCodeCheckTimer) {
|
||||
clearTimeout(userCodeCheckTimer)
|
||||
}
|
||||
clearScannerSubmitTimer()
|
||||
})
|
||||
|
||||
defineExpose({
|
||||
focusPrimaryInput,
|
||||
focusUserCodeInput,
|
||||
submitEntry,
|
||||
resetForm,
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped src="./entry-panel.css"></style>
|
||||
@@ -0,0 +1,340 @@
|
||||
.main-top-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(340px, 1fr);
|
||||
gap: 20px;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.workbench-panel,
|
||||
.records-panel {
|
||||
background: #ffffff;
|
||||
border-radius: 20px;
|
||||
border: 1px solid #e5e7eb;
|
||||
padding: 28px;
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 16px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.column-header {
|
||||
padding-bottom: 18px;
|
||||
border-bottom: 1px solid #eef2f7;
|
||||
margin-bottom: 22px;
|
||||
}
|
||||
|
||||
.panel-metric-card {
|
||||
min-width: 240px;
|
||||
min-height: 56px;
|
||||
padding: 12px 16px;
|
||||
border-radius: 16px;
|
||||
background: #f8fbff;
|
||||
border: 1px solid #e3ebf7;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.panel-metric-label {
|
||||
font-size: 13px;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.panel-metric-value {
|
||||
font-size: 28px;
|
||||
line-height: 1;
|
||||
font-weight: 700;
|
||||
color: #2563eb;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.metric-eye-button {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: 0;
|
||||
border-radius: 999px;
|
||||
background: transparent;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #94a3b8;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.metric-eye-button svg {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
.metric-eye-button.is-hidden {
|
||||
color: #c0cad8;
|
||||
}
|
||||
|
||||
.metric-eye-button:hover {
|
||||
background: rgba(37, 99, 235, 0.08);
|
||||
color: #2563eb;
|
||||
}
|
||||
|
||||
.panel-title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #2563eb;
|
||||
}
|
||||
|
||||
.panel-subtitle {
|
||||
margin-top: 8px;
|
||||
font-size: 14px;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.workbench-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.field-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.field-row-split {
|
||||
grid-template-columns: minmax(0, 1fr) 180px;
|
||||
}
|
||||
|
||||
.field-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.field-group-quantity {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.field-group label {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #334155;
|
||||
}
|
||||
|
||||
.field-error-tip {
|
||||
font-size: 12px;
|
||||
color: #ef4444;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.field-primary :deep(.el-input__wrapper),
|
||||
.field-row :deep(.el-input__wrapper),
|
||||
.field-row :deep(.el-input-number .el-input__wrapper) {
|
||||
min-height: 58px;
|
||||
border-radius: 14px;
|
||||
box-shadow: 0 0 0 1px #dbe4f0 inset;
|
||||
}
|
||||
|
||||
.field-primary :deep(.el-input__inner),
|
||||
.field-row :deep(.el-input__inner),
|
||||
.field-row :deep(.el-input-number .el-input__inner) {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.field-primary :deep(.el-input__wrapper.is-focus),
|
||||
.field-row :deep(.el-input__wrapper.is-focus),
|
||||
.field-row :deep(.el-input-number .el-input__wrapper.is-focus) {
|
||||
box-shadow: 0 0 0 1px var(--el-color-primary) inset;
|
||||
}
|
||||
|
||||
.quantity-input {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.quantity-input :deep(.el-input__wrapper) {
|
||||
padding-right: 40px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.quantity-input :deep(.el-input-number__increase),
|
||||
.quantity-input :deep(.el-input-number__decrease) {
|
||||
right: 1px;
|
||||
width: 36px;
|
||||
margin: 0;
|
||||
background: #f8fafc;
|
||||
color: #94a3b8;
|
||||
border-left: 1px solid #dbe4f0;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.quantity-input :deep(.el-input-number__increase) {
|
||||
top: 1px;
|
||||
height: calc(50% - 0.5px);
|
||||
border-top-right-radius: 13px;
|
||||
border-bottom: 1px solid #e5edf6;
|
||||
}
|
||||
|
||||
.quantity-input :deep(.el-input-number__decrease) {
|
||||
bottom: 1px;
|
||||
height: calc(50% - 0.5px);
|
||||
border-bottom-right-radius: 13px;
|
||||
}
|
||||
|
||||
.quantity-input :deep(.el-input-number__increase:hover),
|
||||
.quantity-input :deep(.el-input-number__decrease:hover) {
|
||||
color: var(--el-color-primary);
|
||||
background: #f1f5f9;
|
||||
}
|
||||
|
||||
.field-label-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.lock-switch {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
font-size: 13px;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.action-row {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.action-row :deep(.el-button) {
|
||||
min-width: 160px;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.dashboard-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.dashboard-title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
.dashboard-caption {
|
||||
font-size: 13px;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.station-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.station-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 14px 16px;
|
||||
border-radius: 14px;
|
||||
background: #ffffff;
|
||||
border: 1px solid #e6edf6;
|
||||
}
|
||||
|
||||
.station-icon {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border-radius: 999px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #e8f1ff;
|
||||
color: #3b82f6;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.station-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.station-info strong {
|
||||
font-size: 16px;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
.station-card b {
|
||||
margin-left: auto;
|
||||
font-size: 32px;
|
||||
line-height: 1;
|
||||
color: #2563eb;
|
||||
}
|
||||
|
||||
.records-header {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.records-panel :deep(.el-table) {
|
||||
--el-table-border-color: #eef2f7;
|
||||
--el-table-header-bg-color: #f8fafc;
|
||||
}
|
||||
|
||||
.records-panel :deep(.el-table th.el-table__cell) {
|
||||
font-weight: 600;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.records-panel :deep(.el-table td.el-table__cell) {
|
||||
padding-top: 14px;
|
||||
padding-bottom: 14px;
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
.panel-header,
|
||||
.field-label-row,
|
||||
.dashboard-header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.panel-metric-card {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.main-top-grid,
|
||||
.station-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.field-row-split {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.workbench-panel,
|
||||
.records-panel {
|
||||
padding: 18px;
|
||||
border-radius: 16px;
|
||||
}
|
||||
|
||||
.action-row {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.action-row :deep(.el-button) {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
<template>
|
||||
<div class="produce-work-page exception-page">
|
||||
<div class="page-shell">
|
||||
<section class="page-hero exception-hero">
|
||||
<div>
|
||||
<span class="eyebrow">现场提报</span>
|
||||
<h1>异常处理</h1>
|
||||
<p>先校验订单,再选择异常类型与原因,补充现场信息后提交。</p>
|
||||
</div>
|
||||
<div class="hero-user">
|
||||
<strong>{{ currentUser.name || '未登录' }}</strong>
|
||||
<span>{{ currentUser.code || '请先登录' }}</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="summary-grid">
|
||||
<div v-for="item in summaryItems" :key="item.label" class="summary-card">
|
||||
<strong :class="item.type">{{ item.value }}</strong>
|
||||
<span>{{ item.label }}</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="content-grid">
|
||||
<main class="main-column">
|
||||
<section class="panel">
|
||||
<div class="panel-title"><span>1</span><div><h2>扫描或录入单号</h2><p>支持订单号、整机编码或设备条码</p></div></div>
|
||||
<div class="inline-form">
|
||||
<el-input v-model="form.orderNo" size="large" clearable placeholder="请输入单号 / 条码" @keyup.enter="checkOrder" />
|
||||
<el-button type="primary" size="large" :loading="checking" @click="checkOrder">校验单号</el-button>
|
||||
</div>
|
||||
<div v-if="orderMessage" class="validation" :class="orderValid ? 'success' : 'error'">{{ orderMessage }}</div>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<div class="panel-title"><span>2</span><div><h2>选择处理类型</h2><p>根据现场异常选择对应的处理分类</p></div><em>必选</em></div>
|
||||
<div class="option-grid type-grid">
|
||||
<button v-for="item in typeOptions" :key="item.value" type="button" :class="['choice-card', { active: form.type === item.value }]" @click="selectType(item.value)">
|
||||
<strong>{{ item.label }}</strong><small>{{ item.desc || '按异常设置执行处理' }}</small>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="sub-title"><h2>3. 选择异常原因</h2><span>快速点选</span></div>
|
||||
<div class="option-grid reason-grid">
|
||||
<button v-for="item in activeReasons" :key="item.value" type="button" :class="['reason-card', { active: form.reason === item.value }]" @click="selectReason(item.value)">{{ item.label }}</button>
|
||||
</div>
|
||||
<div v-if="activeIssues.length" class="issue-box">
|
||||
<div class="sub-title"><h2>具体问题</h2><span>{{ activeReasons.find(item => item.value === form.reason)?.label }}</span></div>
|
||||
<el-select v-model="form.issue" filterable clearable class="full-width" placeholder="请选择具体问题">
|
||||
<el-option v-for="item in activeIssues" :key="item.value" :label="item.label" :value="item.value" />
|
||||
</el-select>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<div class="panel-title"><span>4</span><div><h2>现场取证</h2><p>最多上传 4 张现场照片</p></div><em class="optional">选填</em></div>
|
||||
<el-upload v-model:file-list="imageFiles" list-type="picture-card" :auto-upload="false" :limit="4" accept="image/*" :on-exceed="handleImageExceed">
|
||||
<span class="upload-plus">+</span>
|
||||
</el-upload>
|
||||
<el-input v-model="form.remark" class="remark-input" type="textarea" :rows="4" maxlength="200" show-word-limit placeholder="补充现场情况、处理建议、责任工位等" />
|
||||
</section>
|
||||
|
||||
<div class="form-actions">
|
||||
<el-button size="large" @click="resetForm">重置</el-button>
|
||||
<el-button type="primary" size="large" :loading="submitting" @click="submit">提交异常</el-button>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<aside class="side-column">
|
||||
<section class="panel operator-panel"><h2>提报信息</h2><div><span>提报人</span><strong>{{ currentUser.name || '-' }}</strong></div><div><span>工号</span><strong>{{ currentUser.code || '-' }}</strong></div><div><span>提交时间</span><strong>{{ nowText }}</strong></div></section>
|
||||
<section class="panel"><div class="side-title"><h2>最近异常</h2><span>现场回看</span></div><el-empty v-if="!recentExceptions.length" description="暂无异常记录" :image-size="70" /><div v-for="item in recentExceptions" :key="item.id" class="recent-item"><div><strong>{{ item.order_no || '-' }}</strong><p>{{ item.type_name || '-' }} · {{ [item.reason_name, item.issue_name].filter(Boolean).join(' · ') }}</p></div><el-tag :type="item.status === 2 ? 'success' : item.status === 1 ? 'warning' : 'danger'">{{ item.status_name || '待处理' }}</el-tag></div></section>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ElMessage } from 'element-plus'
|
||||
import type { UploadUserFile } from 'element-plus'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useMemberStore } from '~/stores/member'
|
||||
import { checkExceptionOrder, getExceptionSettings, getExceptionStatistics, getRecentExceptions, submitExceptionRecord, uploadProduceImage, type ProduceExceptionRecord, type ProduceExceptionSetting } from '~/apps/produce/api/exception'
|
||||
|
||||
const memberStore = useMemberStore()
|
||||
const currentUser = computed(() => ({ name: String(memberStore.info?.nickname || memberStore.info?.real_name || memberStore.info?.username || ''), code: String(memberStore.info?.username || memberStore.info?.user_code || '') }))
|
||||
const form = ref({ orderNo: '', type: '', reason: '', issue: '', remark: '' })
|
||||
const settings = ref<ProduceExceptionSetting[]>([])
|
||||
const recentExceptions = ref<ProduceExceptionRecord[]>([])
|
||||
const summary = ref({ pending: 0, review: 0, done: 0 })
|
||||
const imageFiles = ref<UploadUserFile[]>([])
|
||||
const checking = ref(false)
|
||||
const submitting = ref(false)
|
||||
const orderValid = ref(false)
|
||||
const orderMessage = ref('')
|
||||
const nowText = ref('')
|
||||
const typeOptions = computed(() => settings.value.filter(item => item.setting_type === 'type').map(toOption))
|
||||
const activeReasons = computed(() => settings.value.filter(item => item.setting_type === 'reason' && String(item.parent_id || '') === String(form.value.type)).map(toOption))
|
||||
const activeIssues = computed(() => settings.value.filter(item => item.setting_type === 'issue' && String(item.parent_id || '') === String(form.value.reason)).map(toOption))
|
||||
const summaryItems = computed(() => [{ label: '待处理', value: summary.value.pending, type: 'danger' }, { label: '待复核', value: summary.value.review, type: 'warning' }, { label: '已处理', value: summary.value.done, type: 'success' }])
|
||||
const toOption = (item: ProduceExceptionSetting) => ({ value: String(item.id), label: item.name, desc: item.remark || '' })
|
||||
|
||||
const updateNow = () => { nowText.value = new Date().toLocaleString('zh-CN', { hour12: false }) }
|
||||
const loadData = async () => {
|
||||
try {
|
||||
const [settingData, statisticData, recentData] = await Promise.all([getExceptionSettings(), getExceptionStatistics({ user_code: currentUser.value.code }), getRecentExceptions({ user_code: currentUser.value.code, limit: 5 })])
|
||||
settings.value = Array.isArray(settingData) ? settingData : []
|
||||
summary.value = statisticData || summary.value
|
||||
recentExceptions.value = Array.isArray(recentData) ? recentData : []
|
||||
if (!form.value.type) selectType(typeOptions.value[0]?.value || '')
|
||||
} catch { /* 请求层已提示错误 */ }
|
||||
}
|
||||
const selectType = (value: string) => { form.value.type = value; form.value.reason = activeReasons.value[0]?.value || ''; form.value.issue = activeIssues.value[0]?.value || '' }
|
||||
const selectReason = (value: string) => { form.value.reason = value; form.value.issue = activeIssues.value[0]?.value || '' }
|
||||
const checkOrder = async () => {
|
||||
const orderNo = form.value.orderNo.trim()
|
||||
if (!orderNo) { orderMessage.value = '请输入单号或条码'; orderValid.value = false; return }
|
||||
checking.value = true
|
||||
try { const result = await checkExceptionOrder(orderNo); orderValid.value = true; orderMessage.value = `已匹配${result?.reference_name || '记录'}` } catch { orderValid.value = false; orderMessage.value = '未找到对应订单或整机记录' } finally { checking.value = false }
|
||||
}
|
||||
const handleImageExceed = () => ElMessage.warning('最多上传 4 张图片')
|
||||
const resetForm = () => { form.value = { orderNo: '', type: typeOptions.value[0]?.value || '', reason: '', issue: '', remark: '' }; imageFiles.value = []; orderValid.value = false; orderMessage.value = ''; selectType(form.value.type); updateNow() }
|
||||
const submit = async () => {
|
||||
if (!orderValid.value || !form.value.orderNo.trim()) { await checkOrder(); if (!orderValid.value) return }
|
||||
if (!form.value.type || !form.value.reason || (activeIssues.value.length && !form.value.issue)) { ElMessage.warning('请选择处理类型、异常原因和具体问题'); return }
|
||||
if (!currentUser.value.code) { ElMessage.warning('请先登录后再提报异常'); return }
|
||||
submitting.value = true
|
||||
try {
|
||||
const images: string[] = []
|
||||
for (const file of imageFiles.value) if (file.raw) { const url = await uploadProduceImage(file.raw, 'exception'); if (url) images.push(url) }
|
||||
const response = await submitExceptionRecord({ order_no: form.value.orderNo.trim(), user_code: currentUser.value.code, type_id: form.value.type, reason_id: form.value.reason, issue_id: form.value.issue || '', images, remark: form.value.remark.trim() })
|
||||
if (response?.code !== 0) { ElMessage.error(response?.msg || '异常提交失败'); return }
|
||||
ElMessage.success('异常已提交'); resetForm(); await loadData()
|
||||
} finally { submitting.value = false }
|
||||
}
|
||||
onMounted(() => { updateNow(); void loadData() })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.produce-work-page { min-height: calc(100vh - 60px); padding: 28px; background: #f5f7fb; color: #17233d; }
|
||||
.page-shell { max-width: 1440px; margin: 0 auto; }
|
||||
.page-hero { display:flex; align-items:center; justify-content:space-between; gap:24px; padding:32px 36px; border-radius:22px; color:#fff; box-shadow:0 14px 32px rgba(37,99,235,.12); }
|
||||
.exception-hero { background:linear-gradient(135deg,#ff9d42,#ef625f); }
|
||||
.eyebrow { font-size:13px; opacity:.85; } h1 { margin:8px 0 0; font-size:30px; } .page-hero p { margin:8px 0 0; opacity:.86; }
|
||||
.hero-user { min-width:150px; padding:16px 20px; border-radius:14px; background:rgba(255,255,255,.16); text-align:right; } .hero-user strong,.hero-user span { display:block; } .hero-user span { margin-top:6px; font-size:13px; opacity:.8; }
|
||||
.summary-grid { display:grid; grid-template-columns:repeat(3,1fr); gap:16px; margin:20px 0; } .summary-card,.panel { background:#fff; border:1px solid #e7edf5; border-radius:18px; box-shadow:0 8px 24px rgba(36,59,92,.04); } .summary-card { padding:18px 22px; } .summary-card strong,.summary-card span { display:block; } .summary-card strong { font-size:28px; } .summary-card span { margin-top:6px; color:#8a94a6; font-size:13px; } .danger { color:#ef625f; } .warning { color:#e59a2f; } .success { color:#26b36c; }
|
||||
.content-grid { display:grid; grid-template-columns:minmax(0,1fr) 350px; gap:20px; align-items:start; } .main-column,.side-column { display:flex; flex-direction:column; gap:20px; } .panel { padding:26px; } .panel-title,.side-title,.sub-title { display:flex; align-items:flex-start; gap:14px; } .panel-title > span { display:flex; align-items:center; justify-content:center; width:30px; height:30px; border-radius:50%; background:#fff0e5; color:#e97020; font-weight:700; flex:none; } h2 { margin:0; font-size:17px; } .panel-title p { margin:6px 0 0; color:#9aa4b4; font-size:13px; } .panel-title em { margin-left:auto; padding:5px 10px; border-radius:999px; color:#e97020; background:#fff3e8; font-size:12px; font-style:normal; } .panel-title em.optional { color:#8793a6; background:#f3f5f8; }
|
||||
.inline-form { display:flex; gap:12px; margin-top:20px; } .inline-form .el-input { flex:1; } .validation { margin-top:10px; font-size:13px; } .validation.success { color:#26b36c; } .validation.error { color:#ef625f; } .option-grid { display:grid; gap:12px; margin-top:18px; } .type-grid { grid-template-columns:repeat(2,1fr); } button { font:inherit; cursor:pointer; } .choice-card,.reason-card { border:1px solid #e6edf5; border-radius:14px; background:#f8fafc; text-align:left; transition:.2s; } .choice-card { padding:16px; } .choice-card strong,.choice-card small { display:block; } .choice-card strong { color:#2b3853; } .choice-card small { margin-top:8px; color:#9aa4b4; } .choice-card.active,.reason-card.active { border-color:#ff9a3c; background:#fff5ed; color:#e97020; } .sub-title { align-items:center; justify-content:space-between; margin-top:26px; } .sub-title span,.side-title span { color:#9aa4b4; font-size:12px; } .reason-grid { grid-template-columns:repeat(3,1fr); } .reason-card { min-height:46px; padding:8px; text-align:center; color:#56647a; } .issue-box { margin-top:18px; padding:16px; border-radius:14px; background:#fffaf5; } .issue-box .sub-title { margin:0 0 12px; } .full-width { width:100%; }
|
||||
:deep(.el-upload--picture-card),:deep(.el-upload-list__item) { width:92px; height:92px; } .upload-plus { font-size:30px; color:#ff9a3c; } .remark-input { margin-top:18px; } .form-actions { display:flex; justify-content:flex-end; gap:12px; } .operator-panel h2 { margin-bottom:18px; } .operator-panel div { display:flex; justify-content:space-between; padding:13px 0; border-bottom:1px solid #eef2f7; font-size:13px; } .operator-panel div:last-child { border-bottom:0; } .operator-panel span { color:#9aa4b4; } .side-title { align-items:center; justify-content:space-between; } .recent-item { display:flex; justify-content:space-between; gap:10px; padding:16px 0; border-bottom:1px solid #eef2f7; } .recent-item:last-child { border-bottom:0; } .recent-item strong { font-size:14px; } .recent-item p { margin:6px 0 0; color:#8a94a6; font-size:12px; }
|
||||
@media (max-width: 900px) { .produce-work-page { padding:16px; } .content-grid { grid-template-columns:1fr; } .side-column { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); } } @media (max-width: 640px) { .page-hero { padding:24px; align-items:flex-start; flex-direction:column; } .hero-user { align-self:stretch; text-align:left; } .summary-grid,.side-column { grid-template-columns:1fr; } .type-grid,.reason-grid { grid-template-columns:1fr; } .inline-form { flex-direction:column; } .form-actions { justify-content:stretch; } .form-actions .el-button { flex:1; } }
|
||||
</style>
|
||||
@@ -0,0 +1,179 @@
|
||||
<template>
|
||||
<div class="produce-query-page">
|
||||
<div class="page-shell">
|
||||
<section class="page-hero">
|
||||
<div>
|
||||
<span class="eyebrow">生产追溯中心</span>
|
||||
<h1>综合查询工作台</h1>
|
||||
<p>按订单号查看生产、异常和维修全过程;也可以独立查询各类业务记录。</p>
|
||||
</div>
|
||||
<div class="hero-badge"><strong>{{ timeline.total }}</strong><span>订单时间线记录</span></div>
|
||||
</section>
|
||||
|
||||
<section class="panel timeline-panel">
|
||||
<div class="panel-heading"><div><h2>订单全流程时间线</h2><p>订单号查询会汇总扫码、整机、异常和维修记录,并按发生时间排序。</p></div></div>
|
||||
<div class="search-row">
|
||||
<el-input v-model="timelineOrderNo" size="large" clearable placeholder="请输入订单号" @keyup.enter="loadTimeline">
|
||||
<template #prefix><el-icon><Search /></el-icon></template>
|
||||
</el-input>
|
||||
<el-button type="primary" size="large" :loading="timelineLoading" @click="loadTimeline">查询时间线</el-button>
|
||||
</div>
|
||||
<el-alert v-if="timelineMessage" :title="timelineMessage" :type="timeline.total ? 'success' : 'info'" :closable="false" class="timeline-alert" />
|
||||
<el-empty v-if="timelineLoaded && !timelineLoading && !timeline.items.length" description="该订单暂无相关记录" :image-size="90" />
|
||||
<div v-else-if="timeline.items.length" class="timeline-list">
|
||||
<el-table :data="timeline.items" stripe class="timeline-table" empty-text="暂无时间线记录">
|
||||
<el-table-column prop="event_date" label="发生时间" width="170" />
|
||||
<el-table-column label="记录类型" width="112">
|
||||
<template #default="scope"><el-tag :type="tagType(scope.row.record_type)" effect="light" round>{{ scope.row.record_type_name }}</el-tag></template>
|
||||
</el-table-column>
|
||||
<el-table-column label="订单 / 产品" min-width="220" show-overflow-tooltip>
|
||||
<template #default="scope">
|
||||
<div class="timeline-content-cell">
|
||||
<span class="content-mark" aria-hidden="true" />
|
||||
<div class="timeline-main-text">
|
||||
<strong>{{ scope.row.order_no || timeline.order_no || '-' }}</strong>
|
||||
<small v-if="scope.row.product_code">产品:{{ scope.row.product_code }}</small>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="工序节点" min-width="160" show-overflow-tooltip>
|
||||
<template #default="scope">
|
||||
<div class="timeline-node-cell">
|
||||
<strong>{{ scope.row.process_name || '未指定工序' }}</strong>
|
||||
<small v-if="scope.row.step_name">工位:{{ scope.row.step_name }}</small>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态 / 结果" min-width="155" show-overflow-tooltip>
|
||||
<template #default="scope">
|
||||
<span class="timeline-status">{{ [scope.row.status_name, scope.row.result_name].filter(Boolean).join(' · ') || '已记录' }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="quantity" label="数量" width="78" align="right">
|
||||
<template #default="scope">{{ scope.row.quantity ?? '—' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="业务详情" min-width="280" show-overflow-tooltip>
|
||||
<template #default="scope">
|
||||
<div v-if="scope.row.record_type === 'exception'" class="timeline-detail-cell">
|
||||
<span><b>异常原因</b>{{ scope.row.exception_reason || '未填写' }}</span>
|
||||
<span><b>具体问题</b>{{ scope.row.exception_content || '未填写' }}</span>
|
||||
</div>
|
||||
<div v-else-if="scope.row.record_type === 'repair'" class="timeline-detail-cell">
|
||||
<span><b>物料分类</b>{{ scope.row.material_category || '未填写' }}</span>
|
||||
<span><b>故障描述</b>{{ scope.row.fault_description || '未填写' }}</span>
|
||||
</div>
|
||||
<span v-else class="muted-placeholder">—</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作人" min-width="130" show-overflow-tooltip>
|
||||
<template #default="scope">
|
||||
<div class="timeline-operator-cell">
|
||||
<span>{{ scope.row.operator || '未记录' }}</span>
|
||||
<small v-if="scope.row.user_code">工号:{{ scope.row.user_code }}</small>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="remark" label="备注" min-width="180" show-overflow-tooltip />
|
||||
</el-table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel independent-panel">
|
||||
<div class="panel-heading"><div><h2>独立记录查询</h2><p>切换记录类型,按条件分页查询。</p></div><el-button @click="resetIndependent">重置</el-button></div>
|
||||
<el-tabs v-model="filters.record_type" class="query-tabs" @tab-change="handleRecordTypeChange">
|
||||
<el-tab-pane label="扫码记录" name="scan" />
|
||||
<el-tab-pane label="整机记录" name="product" />
|
||||
<el-tab-pane label="异常记录" name="exception" />
|
||||
<el-tab-pane label="维修记录" name="repair" />
|
||||
</el-tabs>
|
||||
<el-form :inline="true" class="filter-form" @submit.prevent="loadRecords">
|
||||
<el-form-item label="关键词"><el-input v-model="filters.keyword" clearable :placeholder="keywordPlaceholder" /></el-form-item>
|
||||
<el-form-item label="订单号"><el-input v-model="filters.order_no" clearable placeholder="精确订单号" /></el-form-item>
|
||||
<el-form-item v-if="filters.record_type !== 'repair'" label="用户工号"><el-input v-model="filters.user_code" clearable placeholder="用户工号" /></el-form-item>
|
||||
<el-form-item label="日期范围"><el-date-picker v-model="dateRange" type="daterange" value-format="YYYY-MM-DD" range-separator="至" start-placeholder="开始日期" end-placeholder="结束日期" /></el-form-item>
|
||||
<el-form-item><el-button type="primary" :loading="recordLoading" @click="loadRecords">查询</el-button></el-form-item>
|
||||
</el-form>
|
||||
<el-table v-loading="recordLoading" :data="recordPage.items" stripe class="record-table" empty-text="暂无查询记录" @row-dblclick="showRecordDetail">
|
||||
<el-table-column prop="event_date" label="发生时间" width="170" />
|
||||
<el-table-column prop="record_type_name" label="记录类型" width="112"><template #default="scope"><el-tag :type="tagType(scope.row.record_type)" effect="light" round>{{ scope.row.record_type_name }}</el-tag></template></el-table-column>
|
||||
<el-table-column prop="order_no" label="订单号" min-width="160" show-overflow-tooltip />
|
||||
<el-table-column prop="product_code" label="产品编码" min-width="145" show-overflow-tooltip />
|
||||
<el-table-column label="工序节点" min-width="160" show-overflow-tooltip><template #default="scope">{{ [scope.row.process_name, scope.row.step_name].filter(Boolean).join(' / ') || '—' }}</template></el-table-column>
|
||||
<el-table-column label="状态 / 结果" min-width="145" show-overflow-tooltip><template #default="scope">{{ [scope.row.status_name, scope.row.result_name].filter(Boolean).join(' · ') || '—' }}</template></el-table-column>
|
||||
<el-table-column label="操作人" min-width="125" show-overflow-tooltip><template #default="scope">{{ scope.row.operator || scope.row.user_code || '未记录' }}</template></el-table-column>
|
||||
<el-table-column prop="remark" label="备注" min-width="190" show-overflow-tooltip />
|
||||
</el-table>
|
||||
<div class="pagination-row"><span>共 {{ recordPage.total }} 条</span><el-pagination v-model:current-page="recordPage.page" v-model:page-size="recordPage.limit" layout="prev, pager, next, sizes" :page-sizes="[20, 50, 100]" :total="recordPage.total" @current-change="loadRecords" @size-change="loadRecords" /></div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Search } from '@element-plus/icons-vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { queryProduceRecords, queryProduceTimeline, type ProduceQueryRecord, type ProduceQueryRecordType } from '~/apps/produce/api/query'
|
||||
|
||||
const timelineOrderNo = ref('')
|
||||
const timelineLoading = ref(false)
|
||||
const timelineLoaded = ref(false)
|
||||
const timelineMessage = ref('')
|
||||
const timeline = reactive<{ order_no: string; total: number; items: ProduceQueryRecord[] }>({ order_no: '', total: 0, items: [] })
|
||||
const filters = reactive<{ record_type: ProduceQueryRecordType; keyword: string; order_no: string; user_code: string; start_date?: string; end_date?: string }>({ record_type: 'scan', keyword: '', order_no: '', user_code: '' })
|
||||
const dateRange = ref<string[]>([])
|
||||
const recordLoading = ref(false)
|
||||
const recordPage = reactive({ items: [] as ProduceQueryRecord[], total: 0, page: 1, limit: 20 })
|
||||
|
||||
const tagTypes: Record<ProduceQueryRecordType, 'primary' | 'success' | 'danger' | 'warning'> = { scan: 'primary', product: 'success', exception: 'danger', repair: 'warning' }
|
||||
const tagType = (type: ProduceQueryRecordType) => tagTypes[type]
|
||||
const keywordPlaceholder = computed(() => filters.record_type === 'repair' ? '维修单、产品、人员或备注' : '订单、产品、人员或备注')
|
||||
const handleRecordTypeChange = () => { recordPage.page = 1; void loadRecords() }
|
||||
const showRecordDetail = (row: ProduceQueryRecord) => { ElMessage.info(`${row.record_type_name}详情功能待接入`) }
|
||||
const loadTimeline = async () => { const orderNo = timelineOrderNo.value.trim(); if (!orderNo) return ElMessage.warning('请输入订单号'); timelineLoading.value = true; timelineLoaded.value = false; timelineMessage.value = ''; try { const data = await queryProduceTimeline(orderNo); timeline.order_no = data?.order_no || orderNo; timeline.total = Number(data?.total || 0); timeline.items = Array.isArray(data?.items) ? data.items : []; timelineMessage.value = timeline.total ? `已找到 ${timeline.total} 条全流程记录` : '该订单暂无相关记录'; timelineLoaded.value = true } catch { timeline.order_no = orderNo; timeline.total = 0; timeline.items = []; timelineMessage.value = '订单查询失败,请检查订单号后重试'; timelineLoaded.value = true } finally { timelineLoading.value = false } }
|
||||
const loadRecords = async () => { recordLoading.value = true; try { const data = await queryProduceRecords({ ...filters, ...(dateRange.value.length ? { start_date: dateRange.value[0], end_date: dateRange.value[1] } : {}), page: recordPage.page, limit: recordPage.limit }); recordPage.items = Array.isArray(data?.items) ? data.items : []; recordPage.total = Number(data?.total || 0) } catch { recordPage.items = []; recordPage.total = 0; ElMessage.error('记录查询失败,请稍后重试') } finally { recordLoading.value = false } }
|
||||
const resetIndependent = () => { filters.record_type = 'scan'; filters.keyword = ''; filters.order_no = ''; filters.user_code = ''; dateRange.value = []; recordPage.page = 1; void loadRecords() }
|
||||
onMounted(() => { void loadRecords() })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.produce-query-page { min-height: calc(100vh - 60px); padding: 28px; color: #17233d; background: #f5f7fb; }
|
||||
.page-shell { max-width: 1440px; margin: 0 auto; }
|
||||
.page-hero { display: flex; align-items: center; justify-content: space-between; gap: 24px; padding: 32px 36px; color: #fff; background: linear-gradient(135deg, #315efb, #6a67df); border-radius: 22px; box-shadow: 0 14px 32px rgba(49, 94, 251, .16); }
|
||||
.eyebrow { font-size: 13px; opacity: .85; }
|
||||
.page-hero h1 { margin: 8px 0 0; font-size: 30px; }
|
||||
.page-hero p { margin: 8px 0 0; opacity: .86; }
|
||||
.hero-badge { min-width: 150px; padding: 16px 20px; text-align: right; background: rgba(255, 255, 255, .16); border-radius: 14px; }
|
||||
.hero-badge strong, .hero-badge span { display: block; }
|
||||
.hero-badge strong { font-size: 28px; }
|
||||
.hero-badge span { margin-top: 5px; font-size: 13px; opacity: .82; }
|
||||
.panel { margin-top: 20px; padding: 26px; background: #fff; border: 1px solid #e7edf5; border-radius: 18px; box-shadow: 0 8px 24px rgba(36, 59, 92, .04); }
|
||||
.panel-heading { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin-bottom: 20px; }
|
||||
.panel-heading h2 { margin: 0; font-size: 18px; }
|
||||
.panel-heading p { margin: 6px 0 0; color: #8a94a6; font-size: 13px; }
|
||||
.search-row { display: flex; gap: 12px; }
|
||||
.search-row .el-input { flex: 1; }
|
||||
.timeline-alert { margin-top: 16px; }
|
||||
.timeline-list { margin: 24px 0 0; overflow-x: auto; }
|
||||
.timeline-table, .record-table { min-width: 1100px; overflow: hidden; border: 1px solid #e7edf5; border-radius: 12px; }
|
||||
.timeline-table :deep(.el-table__header th), .record-table :deep(.el-table__header th) { height: 48px; color: #5d6b82; font-size: 13px; font-weight: 600; background: #f7f9fc; }
|
||||
.timeline-table :deep(.el-table__cell), .record-table :deep(.el-table__cell) { padding: 12px 0; color: #34425b; font-size: 13px; }
|
||||
.timeline-table :deep(.el-table__row:hover > td), .record-table :deep(.el-table__row:hover > td) { background: #f4f7ff !important; }
|
||||
.timeline-content-cell, .timeline-node-cell { display: flex; align-items: center; min-width: 0; }
|
||||
.timeline-content-cell { gap: 10px; }
|
||||
.timeline-main-text, .timeline-node-cell, .timeline-operator-cell { min-width: 0; }
|
||||
.timeline-content-cell strong, .timeline-node-cell strong { display: block; overflow: hidden; color: #24324b; font-weight: 600; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.timeline-content-cell small, .timeline-node-cell small, .timeline-operator-cell small { display: block; margin-top: 4px; overflow: hidden; color: #8a94a6; font-size: 12px; line-height: 1.2; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.content-mark { width: 8px; height: 8px; flex: none; background: #315efb; border-radius: 50%; box-shadow: 0 0 0 4px #e5ebff; }
|
||||
.timeline-status { color: #3157c8; font-weight: 500; }
|
||||
.timeline-detail-cell { display: grid; gap: 5px; min-width: 0; line-height: 1.35; }
|
||||
.timeline-detail-cell span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.timeline-detail-cell b { display: inline-block; min-width: 64px; margin-right: 6px; color: #8a94a6; font-size: 12px; font-weight: 500; }
|
||||
.muted-placeholder { color: #b5bdca; }
|
||||
.filter-form { display: flex; flex-wrap: wrap; margin-bottom: 4px; }
|
||||
.record-table { margin-top: 14px; }
|
||||
.pagination-row { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin-top: 18px; color: #8a94a6; font-size: 13px; }
|
||||
.pagination-row .el-pagination { margin-left: auto; }
|
||||
@media (max-width: 640px) { .produce-query-page { padding: 16px; } .page-hero { align-items: flex-start; flex-direction: column; padding: 24px; } .hero-badge { align-self: stretch; text-align: left; } .search-row { flex-direction: column; } .panel { padding: 18px; } .pagination-row { align-items: flex-start; flex-direction: column; } .pagination-row .el-pagination { margin-left: 0; } }
|
||||
</style>
|
||||
@@ -0,0 +1,105 @@
|
||||
<template>
|
||||
<div class="produce-work-page repair-page">
|
||||
<div class="page-shell">
|
||||
<section class="page-hero repair-hero">
|
||||
<div><span class="eyebrow">维修工作台</span><h1>维修处理</h1><p>扫描设备,快速完成故障判断、备件登记与维修记录。</p></div>
|
||||
<el-icon :size="44"><Tools /></el-icon>
|
||||
</section>
|
||||
|
||||
<section class="summary-grid">
|
||||
<div v-for="item in summaries" :key="item.label" class="summary-card"><strong :class="item.type">{{ item.value }}</strong><span>{{ item.label }}</span><el-button v-if="item.action" link type="primary" @click="goTasks">查看任务</el-button></div>
|
||||
</section>
|
||||
|
||||
<div class="content-grid">
|
||||
<main class="main-column">
|
||||
<section class="panel">
|
||||
<div class="panel-title"><span>1</span><div><h2>扫描或录入设备</h2><p>支持订单号、整机编码或设备条码</p></div><em>必填</em></div>
|
||||
<div class="inline-form"><el-input v-model="deviceNo" size="large" clearable placeholder="请输入订单号 / 整机编码" @keyup.enter="loadTask" /><el-button type="primary" size="large" :loading="loadingTask" @click="loadTask">查询任务</el-button></div>
|
||||
<div v-if="task" class="task-found"><el-icon><CircleCheckFilled /></el-icon><div><strong>已识别维修对象</strong><span>{{ task.device_no }}</span></div><el-tag type="warning">{{ task.status_name || '待维修' }}</el-tag></div>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<div class="panel-title"><span>2</span><div><h2>异常信息</h2><p>来自异常提报的维修任务</p></div><small>{{ task?.reported_date || '待查询设备' }}</small></div>
|
||||
<el-empty v-if="!task" description="查询设备后加载异常任务" :image-size="72" />
|
||||
<div v-else class="info-grid"><div><span>异常类型</span><strong>{{ task.type_name || '-' }}</strong></div><div><span>异常原因</span><strong>{{ reasonText }}</strong></div><div><span>提报人员</span><strong>{{ task.reporter || task.user_code || '-' }}</strong></div></div>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<div class="panel-title"><span>3</span><div><h2>维修判定</h2><p>选择本次维修的不良类型</p></div><em>必选</em></div>
|
||||
<div class="result-grid"><button v-for="item in resultOptions" :key="item.value" type="button" :class="['result-card', { active: form.repair_result === item.value }]" @click="selectResult(item.value)"><el-icon :size="26"><component :is="item.icon" /></el-icon><strong>{{ item.label }}</strong><small>{{ item.desc }}</small></button></div>
|
||||
</section>
|
||||
|
||||
<section v-if="form.repair_result === 'assembly_mismatch'" class="panel"><div class="panel-title"><span>4</span><div><h2>搭配错误分类</h2><p>选择与本次异常对应的物料分类</p></div><em>必选</em></div><el-checkbox-group v-model="form.category_ids" class="category-grid"><el-checkbox v-for="item in categories" :key="item.id" :label="String(item.id)">{{ item.category_name }}</el-checkbox></el-checkbox-group><el-empty v-if="!categories.length" description="暂无可用物料分类" :image-size="60" /></section>
|
||||
|
||||
<section v-if="form.repair_result === 'operation_defect'" class="panel"><div class="panel-title"><span>4</span><div><h2>作业不良责任</h2><p>选择责任人员,工序步骤随人员记录保存</p></div><em>必选</em></div><el-checkbox-group v-model="form.responsible_ids" class="responsibility-grid"><el-checkbox v-for="item in scanRecords" :key="item.id" :label="String(item.id)"><span>{{ item.operator || item.user_code || '未记录人员' }}</span><small>{{ item.process_name || '-' }} · {{ item.step_name || '-' }}</small></el-checkbox></el-checkbox-group><el-empty v-if="!scanRecords.length" description="当前订单暂无可定责的历史扫码记录" :image-size="60" /></section>
|
||||
|
||||
<section class="panel"><div class="panel-title"><span>{{ form.repair_result === 'material_defect' ? '4' : '5' }}</span><div><h2>维修记录</h2><p>登记更换备件和处理说明,便于后续追溯</p></div></div><div class="part-toolbar"><strong>更换备件</strong><div><el-button size="small" @click="openPartDialog">输入商品编码</el-button><el-button size="small" type="primary" @click="openPartDialog">添加备件</el-button></div></div><el-empty v-if="!form.parts.length" description="暂未添加备件" :image-size="60" /><div v-for="(part, index) in form.parts" :key="part.id" class="part-row"><div class="part-index">{{ index + 1 }}</div><div class="part-info"><strong>{{ part.product_name || part.product_code }}</strong><span>{{ part.product_code }}{{ part.category_name ? ` · ${part.category_name}` : '' }}</span><el-radio-group v-model="part.source" size="small"><el-radio-button label="new">新料</el-radio-button><el-radio-button label="after_sales">售后</el-radio-button><el-radio-button label="refurbished">返修</el-radio-button></el-radio-group><el-select v-model="part.fault_description_ids" multiple collapse-tags filterable clearable placeholder="选择故障点" class="fault-select"><el-option v-for="fault in faultsForPart(part)" :key="fault.id" :label="fault.description" :value="String(fault.id)" /></el-select></div><el-input-number v-model="part.quantity" :min="1" size="small" /><el-button link type="danger" @click="removePart(index)">删除</el-button></div><el-input v-model="form.remark" class="remark-input" type="textarea" :rows="4" maxlength="1000" show-word-limit placeholder="记录维修过程、处理建议或补充说明" /></section>
|
||||
|
||||
<section class="panel"><div class="panel-title"><span>{{ form.repair_result === 'material_defect' ? '5' : '6' }}</span><div><h2>维修凭证</h2><p>可上传维修完成后的现场照片</p></div><em class="optional">选填</em></div><el-upload v-model:file-list="imageFiles" list-type="picture-card" :auto-upload="false" :limit="4" accept="image/*" :on-exceed="handleImageExceed"><span class="upload-plus">+</span></el-upload></section>
|
||||
|
||||
<div class="form-actions"><el-button size="large" @click="resetForm">重置</el-button><el-button type="primary" size="large" :loading="submitting" @click="completeRepair">完成维修</el-button></div>
|
||||
</main>
|
||||
<aside class="side-column"><section class="panel operator-panel"><h2>当前操作人</h2><div><span>姓名</span><strong>{{ currentUser.name || '-' }}</strong></div><div><span>工号</span><strong>{{ currentUser.code || '-' }}</strong></div><div><span>当前任务</span><strong>{{ task?.device_no || '未选择' }}</strong></div></section><section class="panel tip-panel"><h2>处理提示</h2><p>物料不良请登记替换备件;作业不良需选择责任人员;搭配错误需选择对应物料分类。</p><el-button link type="primary" @click="goTasks">查看待维修订单</el-button></section></aside>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-dialog v-model="partDialogVisible" title="添加维修备件" width="460px"><el-input v-model="partCode" autofocus placeholder="请输入商品编码" @keyup.enter="addPart" /><template #footer><el-button @click="partDialogVisible = false">取消</el-button><el-button type="primary" :loading="loadingPart" @click="addPart">确定</el-button></template></el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { CircleCheckFilled, Grid, EditPen, Tools, Box } from '@element-plus/icons-vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import type { UploadUserFile } from 'element-plus'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { navigateTo, useRoute } from 'nuxt/app'
|
||||
import { useMemberStore } from '~/stores/member'
|
||||
import { getFaultDescriptionOptions, getMaterialCategories, getRepairStatistics, getRepairTask, getScanRecords, lookupMaterial, submitRepairRecord, uploadProduceImage, type ProduceRepairTask } from '~/apps/produce/api/exception'
|
||||
|
||||
interface Part { id: string; product_code: string; product_name?: string; category_id?: string | number; category_name?: string; source: string; quantity: number; fault_description_ids: string[] }
|
||||
interface ScanRecord { id: string | number; process_name?: string; step_name?: string; operator?: string; user_code?: string }
|
||||
interface FaultDescription { id: string | number; category_id: string | number; description: string }
|
||||
const memberStore = useMemberStore()
|
||||
const currentUser = computed(() => ({ name: String(memberStore.info?.nickname || memberStore.info?.real_name || memberStore.info?.username || ''), code: String(memberStore.info?.username || memberStore.info?.user_code || '') }))
|
||||
const summaries = ref([{ label: '待维修', value: 0, type: 'warning', action: true }, { label: '处理中', value: 0, type: 'primary' }, { label: '今日完成', value: 0, type: 'success' }])
|
||||
const resultOptions = [{ label: '物料不良', value: 'material_defect', desc: '物料本身存在异常', icon: Box }, { label: '作业不良', value: 'operation_defect', desc: '作业过程导致异常', icon: EditPen }, { label: '搭配错误', value: 'assembly_mismatch', desc: '物料搭配或装配错误', icon: Grid }]
|
||||
const deviceNo = ref('')
|
||||
const task = ref<ProduceRepairTask | null>(null)
|
||||
const categories = ref<Array<{ id: string | number; category_name: string }>>([])
|
||||
const scanRecords = ref<ScanRecord[]>([])
|
||||
const faults = ref<FaultDescription[]>([])
|
||||
const imageFiles = ref<UploadUserFile[]>([])
|
||||
const form = ref({ repair_result: 'material_defect', category_ids: [] as string[], responsible_ids: [] as string[], parts: [] as Part[], remark: '' })
|
||||
const loadingTask = ref(false)
|
||||
const loadingPart = ref(false)
|
||||
const submitting = ref(false)
|
||||
const partDialogVisible = ref(false)
|
||||
const partCode = ref('')
|
||||
const reasonText = computed(() => [task.value?.reason_name, task.value?.issue_name].filter(Boolean).join(' · ') || '-')
|
||||
|
||||
const loadStatistics = async () => {
|
||||
const data = await getRepairStatistics({ user_code: currentUser.value.code }).catch(() => null)
|
||||
if (!data) return
|
||||
summaries.value = [
|
||||
{ label: '待维修', value: Number(data.pending || 0), type: 'warning', action: true },
|
||||
{ label: '处理中', value: Number(data.processing || 0), type: 'primary' },
|
||||
{ label: '今日完成', value: Number(data.completed_today || 0), type: 'success' },
|
||||
]
|
||||
}
|
||||
const loadTask = async () => { const value = deviceNo.value.trim(); if (!value || loadingTask.value) return ElMessage.warning('请输入设备编码或订单号'); loadingTask.value = true; try { task.value = await getRepairTask(value); form.value.responsible_ids = []; scanRecords.value = []; if (task.value?.device_no) { const result = await getScanRecords({ order_no: task.value.device_no, limit: 50 }); scanRecords.value = Array.isArray(result?.items) ? result.items : []; } } finally { loadingTask.value = false } }
|
||||
const selectResult = (value: string) => { form.value.repair_result = value; if (value !== 'assembly_mismatch') form.value.category_ids = []; if (value !== 'operation_defect') form.value.responsible_ids = [] }
|
||||
const openPartDialog = () => { partCode.value = ''; partDialogVisible.value = true }
|
||||
const addPart = async () => { const code = partCode.value.trim(); if (!code) return ElMessage.warning('请输入商品编码'); if (form.value.parts.some((item: Part) => item.product_code === code)) return ElMessage.warning('该物料已添加'); loadingPart.value = true; try { const material = await lookupMaterial(code); const part: Part = { id: `${Date.now()}-${code}`, product_code: material.product_code || code, product_name: material.product_name, category_id: material.category_id, category_name: material.category_name, source: 'new', quantity: 1, fault_description_ids: [] }; form.value.parts.push(part); await loadFaults(); partDialogVisible.value = false } finally { loadingPart.value = false } }
|
||||
const loadFaults = async () => { const ids = [...new Set(form.value.parts.map((item: Part) => String(item.category_id || '')).filter(Boolean))]; faults.value = ids.length ? await getFaultDescriptionOptions(ids) : [] }
|
||||
const faultsForPart = (part: Part) => faults.value.filter((item: FaultDescription) => String(item.category_id) === String(part.category_id))
|
||||
const removePart = (index: number) => { form.value.parts.splice(index, 1); void loadFaults() }
|
||||
const handleImageExceed = () => ElMessage.warning('最多上传 4 张图片')
|
||||
const resetForm = () => { deviceNo.value = ''; task.value = null; scanRecords.value = []; form.value = { repair_result: 'material_defect', category_ids: [], responsible_ids: [], parts: [], remark: '' }; imageFiles.value = [] }
|
||||
const completeRepair = async () => { if (!task.value?.id) return ElMessage.warning('请先查询有效的维修任务'); if (!currentUser.value.code) return ElMessage.warning('请先登录'); if (form.value.repair_result === 'assembly_mismatch' && !form.value.category_ids.length) return ElMessage.warning('请选择搭配错误分类'); if (form.value.repair_result === 'operation_defect' && !form.value.responsible_ids.length) return ElMessage.warning('请选择责任人员'); submitting.value = true; try { const images: string[] = []; for (const file of imageFiles.value) if (file.raw) { const url = await uploadProduceImage(file.raw, 'repair'); if (url) images.push(url) } const response = await submitRepairRecord({ exception_id: task.value.id, user_code: currentUser.value.code, repair_result: form.value.repair_result, images, category_ids: form.value.category_ids, parts: form.value.parts.map((part: Part) => ({ product_code: part.product_code, source: part.source, quantity: part.quantity, fault_description_ids: part.fault_description_ids })), operation_fault_steps: form.value.responsible_ids, responsible_operators: form.value.responsible_ids, remark: form.value.remark.trim() }); if (response?.code !== 0) { ElMessage.error(response?.msg || '维修提交失败'); return } ElMessage.success('维修记录已完成'); resetForm(); void loadStatistics() } finally { submitting.value = false } }
|
||||
const goTasks = () => navigateTo('/produce/repair/tasks')
|
||||
onMounted(() => { void loadStatistics(); void getMaterialCategories().then((data: Array<{ id: string | number; category_name: string }>) => { categories.value = Array.isArray(data) ? data : [] }); const query = useRoute().query.device_no; if (query) { deviceNo.value = String(query); void loadTask() } })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.produce-work-page{min-height:calc(100vh - 60px);padding:28px;background:#f5f7fb;color:#17233d}.page-shell{max-width:1440px;margin:0 auto}.page-hero{display:flex;align-items:center;justify-content:space-between;padding:32px 36px;border-radius:22px;color:#fff;box-shadow:0 14px 32px rgba(37,99,235,.12)}.repair-hero{background:linear-gradient(135deg,#2f6cf6,#35bfa3)}.eyebrow{font-size:13px;opacity:.85}h1{margin:8px 0 0;font-size:30px}.page-hero p{margin:8px 0 0;opacity:.86}.summary-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:16px;margin:20px 0}.summary-card,.panel{background:#fff;border:1px solid #e7edf5;border-radius:18px;box-shadow:0 8px 24px rgba(36,59,92,.04)}.summary-card{display:flex;align-items:center;gap:12px;padding:18px 22px}.summary-card strong{font-size:28px}.summary-card span{color:#8a94a6;font-size:13px}.summary-card .el-button{margin-left:auto}.warning{color:#e59a2f}.primary{color:#2563eb}.success{color:#26b36c}.content-grid{display:grid;grid-template-columns:minmax(0,1fr) 350px;gap:20px;align-items:start}.main-column,.side-column{display:flex;flex-direction:column;gap:20px}.panel{padding:26px}.panel-title{display:flex;align-items:flex-start;gap:14px}.panel-title>span{display:flex;align-items:center;justify-content:center;width:30px;height:30px;border-radius:50%;background:#eaf2ff;color:#2563eb;font-weight:700;flex:none}.panel-title h2{margin:0;font-size:17px}.panel-title p{margin:6px 0 0;color:#9aa4b4;font-size:13px}.panel-title em{margin-left:auto;padding:5px 10px;border-radius:999px;color:#2563eb;background:#edf4ff;font-size:12px;font-style:normal}.panel-title em.optional{color:#8793a6;background:#f3f5f8}.panel-title small{margin-left:auto;color:#9aa4b4}.inline-form{display:flex;gap:12px;margin-top:20px}.inline-form .el-input{flex:1}.task-found{display:flex;align-items:center;gap:12px;margin-top:16px;padding:14px;border-radius:12px;background:#f0fbf6;color:#26b36c}.task-found>div{display:flex;flex-direction:column;gap:4px;flex:1}.task-found span{color:#2b3853;font-weight:600}.task-found .el-tag{margin-left:auto}.info-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:14px;margin-top:22px}.info-grid div{padding:14px;border-radius:12px;background:#f8fafc}.info-grid span,.info-grid strong{display:block}.info-grid span{color:#9aa4b4;font-size:12px}.info-grid strong{margin-top:8px;font-size:14px;word-break:break-all}.result-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:14px;margin-top:20px}.result-card{display:flex;flex-direction:column;align-items:flex-start;gap:8px;padding:18px;border:1px solid #e6edf5;border-radius:14px;background:#f8fafc;color:#56647a;text-align:left;cursor:pointer}.result-card.active{border-color:#409eff;background:#edf4ff;color:#2563eb}.result-card small{color:#9aa4b4}.category-grid,.responsibility-grid{display:flex;flex-wrap:wrap;gap:14px;margin-top:22px}.responsibility-grid .el-checkbox{height:auto;margin:0;padding:12px 16px;border:1px solid #e6edf5;border-radius:12px;background:#f8fafc}.responsibility-grid .el-checkbox.is-checked{border-color:#409eff;background:#edf4ff}.responsibility-grid small{display:block;color:#9aa4b4}.part-toolbar{display:flex;justify-content:space-between;align-items:center;margin:22px 0 12px}.part-row{display:flex;align-items:flex-start;gap:12px;padding:16px 0;border-bottom:1px solid #eef2f7}.part-index{display:flex;align-items:center;justify-content:center;width:28px;height:28px;border-radius:50%;background:#edf4ff;color:#2563eb}.part-info{display:flex;flex-direction:column;gap:8px;flex:1}.part-info>span{color:#8a94a6;font-size:12px}.fault-select{width:100%}.remark-input{margin-top:18px}.form-actions{display:flex;justify-content:flex-end;gap:12px}.operator-panel h2,.tip-panel h2{margin:0 0 18px}.operator-panel div{display:flex;justify-content:space-between;padding:13px 0;border-bottom:1px solid #eef2f7;font-size:13px}.operator-panel div:last-child{border:0}.operator-panel span{color:#9aa4b4}.tip-panel p{color:#718096;font-size:13px;line-height:1.8}.upload-plus{font-size:30px;color:#409eff}:deep(.el-upload--picture-card),:deep(.el-upload-list__item){width:92px;height:92px}@media(max-width:900px){.produce-work-page{padding:16px}.content-grid{grid-template-columns:1fr}.side-column{display:grid;grid-template-columns:repeat(2,minmax(0,1fr))}}@media(max-width:640px){.page-hero{padding:24px;align-items:flex-start;flex-direction:column;gap:18px}.summary-grid,.side-column,.info-grid,.result-grid{grid-template-columns:1fr}.inline-form,.form-actions{flex-direction:column}.form-actions .el-button{width:100%}.part-row{flex-wrap:wrap}}
|
||||
</style>
|
||||
@@ -0,0 +1,51 @@
|
||||
<template>
|
||||
<div class="produce-work-page tasks-page">
|
||||
<div class="page-shell">
|
||||
<section class="page-hero">
|
||||
<div><span class="eyebrow">维修工作台</span><h1>待维修订单</h1><p>选择订单查看异常详情,并进入维修处理。</p></div>
|
||||
<div class="task-count">{{ tasks.length }} 条</div>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<div class="panel-heading"><div><h2>待维修任务</h2><p>按异常提报时间展示尚未处理的维修任务</p></div><el-button :loading="loading" @click="loadTasks">刷新</el-button></div>
|
||||
<el-skeleton v-if="loading" :rows="5" animated />
|
||||
<el-empty v-else-if="!tasks.length" description="暂无待维修订单" :image-size="90" />
|
||||
<div v-else class="task-grid">
|
||||
<article v-for="task in tasks" :key="task.id" class="task-card">
|
||||
<div class="task-card-header"><div><span class="task-label">设备 / 订单号</span><strong>{{ task.device_no }}</strong></div><el-tag type="warning">{{ task.status_name || '待维修' }}</el-tag></div>
|
||||
<div class="task-detail"><span>异常类型</span><strong>{{ task.type_name || '-' }}</strong></div>
|
||||
<div class="task-detail"><span>异常原因</span><strong>{{ reasonText(task) }}</strong></div>
|
||||
<div class="task-footer"><span>{{ task.reported_date || '-' }}</span><span>{{ task.reporter || task.user_code || '-' }}</span></div>
|
||||
<el-button type="primary" class="task-action" @click="startRepair(task.device_no)">开始维修</el-button>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { getRepairTasks, type ProduceRepairTask } from '~/apps/produce/api/exception'
|
||||
|
||||
const loading = ref(false)
|
||||
const tasks = ref<ProduceRepairTask[]>([])
|
||||
const reasonText = (task: ProduceRepairTask) => [task.reason_name, task.issue_name].filter(Boolean).join(' · ') || '-'
|
||||
const loadTasks = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const result = await getRepairTasks({ limit: 100 })
|
||||
tasks.value = Array.isArray(result) ? result : []
|
||||
} catch {
|
||||
tasks.value = []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
const startRepair = (deviceNo: string) => navigateTo({ path: '/produce/repair', query: { device_no: deviceNo } })
|
||||
onMounted(() => { void loadTasks() })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.produce-work-page{min-height:calc(100vh - 60px);padding:28px;background:#f5f7fb;color:#17233d}.page-shell{max-width:1440px;margin:0 auto}.page-hero{display:flex;align-items:center;justify-content:space-between;gap:24px;padding:32px 36px;border-radius:22px;color:#fff;background:linear-gradient(135deg,#2f6cf6,#35bfa3);box-shadow:0 14px 32px rgba(37,99,235,.12)}.eyebrow{font-size:13px;opacity:.85}.page-hero h1{margin:8px 0 0;font-size:30px}.page-hero p{margin:8px 0 0;opacity:.86}.task-count{padding:10px 16px;border-radius:999px;background:rgba(255,255,255,.16);font-size:14px}.panel{margin-top:20px;padding:26px;border:1px solid #e7edf5;border-radius:18px;background:#fff;box-shadow:0 8px 24px rgba(36,59,92,.04)}.panel-heading{display:flex;align-items:center;justify-content:space-between;gap:16px;margin-bottom:22px}.panel-heading h2{margin:0;font-size:18px}.panel-heading p{margin:6px 0 0;color:#9aa4b4;font-size:13px}.task-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:16px}.task-card{padding:20px;border:1px solid #e7edf5;border-radius:16px;background:#fbfcfe}.task-card-header{display:flex;align-items:flex-start;justify-content:space-between;gap:12px}.task-label,.task-detail span{display:block;color:#9aa4b4;font-size:12px}.task-card-header strong{display:block;margin-top:7px;color:#2563eb;font-size:16px;word-break:break-all}.task-detail{display:flex;justify-content:space-between;gap:16px;padding:14px 0;border-bottom:1px solid #eef2f7}.task-detail strong{max-width:68%;text-align:right;font-size:13px;word-break:break-all}.task-footer{display:flex;justify-content:space-between;gap:12px;padding:14px 0;color:#8a94a6;font-size:12px}.task-action{width:100%;margin-top:4px}@media(max-width:1000px){.task-grid{grid-template-columns:repeat(2,minmax(0,1fr))}}@media(max-width:640px){.produce-work-page{padding:16px}.page-hero{padding:24px;align-items:flex-start;flex-direction:column}.task-grid{grid-template-columns:1fr}.panel{padding:18px}}
|
||||
</style>
|
||||
@@ -0,0 +1,42 @@
|
||||
export default [
|
||||
{
|
||||
path: '/produce/data-entry',
|
||||
component: () => import('~/apps/produce/pages/data-entry/index.vue'),
|
||||
meta: {
|
||||
title: '数据录入',
|
||||
layout: 'default',
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '/produce/exception',
|
||||
component: () => import('~/apps/produce/pages/exception/index.vue'),
|
||||
meta: {
|
||||
title: '异常处理',
|
||||
layout: 'default',
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '/produce/repair',
|
||||
component: () => import('~/apps/produce/pages/repair/index.vue'),
|
||||
meta: {
|
||||
title: '维修处理',
|
||||
layout: 'default',
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '/produce/repair/tasks',
|
||||
component: () => import('~/apps/produce/pages/repair/tasks.vue'),
|
||||
meta: {
|
||||
title: '待维修订单',
|
||||
layout: 'default',
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '/produce/query',
|
||||
component: () => import('~/apps/produce/pages/query/index.vue'),
|
||||
meta: {
|
||||
title: '综合查询',
|
||||
layout: 'default',
|
||||
},
|
||||
},
|
||||
]
|
||||
@@ -0,0 +1,387 @@
|
||||
/*
|
||||
1. Prevent padding and border from affecting element width. (https://github.com/mozdevs/cssremedy/issues/4)
|
||||
2. Allow adding a border to an element by just adding a border-width. (https://github.com/tailwindcss/tailwindcss/pull/116)
|
||||
2. [UnoCSS]: allow to override the default border color with css var `--un-default-border-color`
|
||||
*/
|
||||
|
||||
*,
|
||||
::before,
|
||||
::after {
|
||||
box-sizing: border-box; /* 1 */
|
||||
border-width: 0; /* 2 */
|
||||
border-style: solid; /* 2 */
|
||||
border-color: var(--un-default-border-color, #e5e7eb); /* 2 */
|
||||
}
|
||||
|
||||
::before,
|
||||
::after {
|
||||
--un-content: '';
|
||||
}
|
||||
|
||||
/*
|
||||
1. Use a consistent sensible line-height in all browsers.
|
||||
2. Prevent adjustments of font size after orientation changes in iOS.
|
||||
3. Use a more readable tab size.
|
||||
4. Use the user's configured `sans` font-family by default.
|
||||
5. Use the user's configured `sans` font-feature-settings by default.
|
||||
6. Use the user's configured `sans` font-variation-settings by default.
|
||||
7. Disable tap highlights on iOS.
|
||||
*/
|
||||
|
||||
html,
|
||||
:host {
|
||||
line-height: 1.5; /* 1 */
|
||||
-webkit-text-size-adjust: 100%; /* 2 */
|
||||
-moz-tab-size: 4; /* 3 */
|
||||
tab-size: 4; /* 3 */
|
||||
font-family: ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; /* 4 */
|
||||
font-feature-settings: normal; /* 5 */
|
||||
font-variation-settings: normal; /* 6 */
|
||||
-webkit-tap-highlight-color: transparent; /* 7 */
|
||||
}
|
||||
|
||||
/*
|
||||
1. Remove the margin in all browsers.
|
||||
2. Inherit line-height from `html` so users can set them as a class directly on the `html` element.
|
||||
*/
|
||||
|
||||
body {
|
||||
margin: 0; /* 1 */
|
||||
line-height: inherit; /* 2 */
|
||||
}
|
||||
|
||||
/*
|
||||
1. Add the correct height in Firefox.
|
||||
2. Correct the inheritance of border color in Firefox. (https://bugzilla.mozilla.org/show_bug.cgi?id=190655)
|
||||
3. Ensure horizontal rules are visible by default.
|
||||
*/
|
||||
|
||||
hr {
|
||||
height: 0; /* 1 */
|
||||
color: inherit; /* 2 */
|
||||
border-top-width: 1px; /* 3 */
|
||||
}
|
||||
|
||||
/*
|
||||
Add the correct text decoration in Chrome, Edge, and Safari.
|
||||
*/
|
||||
|
||||
abbr:where([title]) {
|
||||
text-decoration: underline dotted;
|
||||
}
|
||||
|
||||
/*
|
||||
Remove the default font size and weight for headings.
|
||||
*/
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6 {
|
||||
font-size: inherit;
|
||||
font-weight: inherit;
|
||||
}
|
||||
|
||||
/*
|
||||
Reset links to optimize for opt-in styling instead of opt-out.
|
||||
*/
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
text-decoration: inherit;
|
||||
}
|
||||
|
||||
/*
|
||||
Add the correct font weight in Edge and Safari.
|
||||
*/
|
||||
|
||||
b,
|
||||
strong {
|
||||
font-weight: bolder;
|
||||
}
|
||||
|
||||
/*
|
||||
1. Use the user's configured `mono` font-family by default.
|
||||
2. Use the user's configured `mono` font-feature-settings by default.
|
||||
3. Use the user's configured `mono` font-variation-settings by default.
|
||||
4. Correct the odd `em` font sizing in all browsers.
|
||||
*/
|
||||
|
||||
code,
|
||||
kbd,
|
||||
samp,
|
||||
pre {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; /* 1 */
|
||||
font-feature-settings: normal; /* 2 */
|
||||
font-variation-settings: normal; /* 3 */
|
||||
font-size: 1em; /* 4 */
|
||||
}
|
||||
|
||||
/*
|
||||
Add the correct font size in all browsers.
|
||||
*/
|
||||
|
||||
small {
|
||||
font-size: 80%;
|
||||
}
|
||||
|
||||
/*
|
||||
Prevent `sub` and `sup` elements from affecting the line height in all browsers.
|
||||
*/
|
||||
|
||||
sub,
|
||||
sup {
|
||||
font-size: 75%;
|
||||
line-height: 0;
|
||||
position: relative;
|
||||
vertical-align: baseline;
|
||||
}
|
||||
|
||||
sub {
|
||||
bottom: -0.25em;
|
||||
}
|
||||
|
||||
sup {
|
||||
top: -0.5em;
|
||||
}
|
||||
|
||||
/*
|
||||
1. Remove text indentation from table contents in Chrome and Safari. (https://bugs.chromium.org/p/chromium/issues/detail?id=999088, https://bugs.webkit.org/show_bug.cgi?id=201297)
|
||||
2. Correct table border color inheritance in all Chrome and Safari. (https://bugs.chromium.org/p/chromium/issues/detail?id=935729, https://bugs.webkit.org/show_bug.cgi?id=195016)
|
||||
3. Remove gaps between table borders by default.
|
||||
*/
|
||||
|
||||
table {
|
||||
text-indent: 0; /* 1 */
|
||||
border-color: inherit; /* 2 */
|
||||
border-collapse: collapse; /* 3 */
|
||||
}
|
||||
|
||||
/*
|
||||
1. Change the font styles in all browsers.
|
||||
2. Remove the margin in Firefox and Safari.
|
||||
3. Remove default padding in all browsers.
|
||||
*/
|
||||
|
||||
button,
|
||||
input,
|
||||
optgroup,
|
||||
select,
|
||||
textarea {
|
||||
font-family: inherit; /* 1 */
|
||||
font-feature-settings: inherit; /* 1 */
|
||||
font-variation-settings: inherit; /* 1 */
|
||||
font-size: 100%; /* 1 */
|
||||
font-weight: inherit; /* 1 */
|
||||
line-height: inherit; /* 1 */
|
||||
color: inherit; /* 1 */
|
||||
margin: 0; /* 2 */
|
||||
padding: 0; /* 3 */
|
||||
}
|
||||
|
||||
/*
|
||||
Remove the inheritance of text transform in Edge and Firefox.
|
||||
*/
|
||||
|
||||
button,
|
||||
select {
|
||||
text-transform: none;
|
||||
}
|
||||
|
||||
/*
|
||||
1. Correct the inability to style clickable types in iOS and Safari.
|
||||
2. Remove default button styles.
|
||||
*/
|
||||
|
||||
button,
|
||||
[type='button'],
|
||||
[type='reset'],
|
||||
[type='submit'] {
|
||||
-webkit-appearance: button; /* 1 */
|
||||
background-color: transparent; /* 2 */
|
||||
background-image: none; /* 2 */
|
||||
}
|
||||
|
||||
/*
|
||||
Use the modern Firefox focus style for all focusable elements.
|
||||
*/
|
||||
|
||||
:-moz-focusring {
|
||||
outline: auto;
|
||||
}
|
||||
|
||||
/*
|
||||
Remove the additional `:invalid` styles in Firefox. (https://github.com/mozilla/gecko-dev/blob/2f9eacd9d3d995c937b4251a5557d95d494c9be1/layout/style/res/forms.css#L728-L737)
|
||||
*/
|
||||
|
||||
:-moz-ui-invalid {
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
/*
|
||||
Add the correct vertical alignment in Chrome and Firefox.
|
||||
*/
|
||||
|
||||
progress {
|
||||
vertical-align: baseline;
|
||||
}
|
||||
|
||||
/*
|
||||
Correct the cursor style of increment and decrement buttons in Safari.
|
||||
*/
|
||||
|
||||
::-webkit-inner-spin-button,
|
||||
::-webkit-outer-spin-button {
|
||||
height: auto;
|
||||
}
|
||||
|
||||
/*
|
||||
1. Correct the odd appearance in Chrome and Safari.
|
||||
2. Correct the outline style in Safari.
|
||||
*/
|
||||
|
||||
[type='search'] {
|
||||
-webkit-appearance: textfield; /* 1 */
|
||||
outline-offset: -2px; /* 2 */
|
||||
}
|
||||
|
||||
/*
|
||||
Remove the inner padding in Chrome and Safari on macOS.
|
||||
*/
|
||||
|
||||
::-webkit-search-decoration {
|
||||
-webkit-appearance: none;
|
||||
}
|
||||
|
||||
/*
|
||||
1. Correct the inability to style clickable types in iOS and Safari.
|
||||
2. Change font properties to `inherit` in Safari.
|
||||
*/
|
||||
|
||||
::-webkit-file-upload-button {
|
||||
-webkit-appearance: button; /* 1 */
|
||||
font: inherit; /* 2 */
|
||||
}
|
||||
|
||||
/*
|
||||
Add the correct display in Chrome and Safari.
|
||||
*/
|
||||
|
||||
summary {
|
||||
display: list-item;
|
||||
}
|
||||
|
||||
/*
|
||||
Removes the default spacing for appropriate elements.
|
||||
*/
|
||||
|
||||
blockquote,
|
||||
dl,
|
||||
dd,
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6,
|
||||
hr,
|
||||
figure,
|
||||
p,
|
||||
pre {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
fieldset {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
legend {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
ol,
|
||||
ul,
|
||||
menu {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
dialog {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/*
|
||||
Prevent resizing textareas horizontally by default.
|
||||
*/
|
||||
|
||||
textarea {
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
/*
|
||||
1. Reset the default placeholder opacity in Firefox. (https://github.com/tailwindlabs/tailwindcss/issues/3300)
|
||||
2. Set the default placeholder color to the user's configured gray 400 color.
|
||||
*/
|
||||
|
||||
input::placeholder,
|
||||
textarea::placeholder {
|
||||
opacity: 1; /* 1 */
|
||||
color: #9ca3af; /* 2 */
|
||||
}
|
||||
|
||||
/*
|
||||
Set the default cursor for buttons.
|
||||
*/
|
||||
|
||||
button,
|
||||
[role="button"] {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/*
|
||||
Make sure disabled buttons don't get the pointer cursor.
|
||||
*/
|
||||
|
||||
:disabled {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/*
|
||||
1. Make replaced elements `display: block` by default. (https://github.com/mozdevs/cssremedy/issues/14)
|
||||
2. Add `vertical-align: middle` to align replaced elements more sensibly by default. (https://github.com/jensimmons/cssremedy/issues/14#issuecomment-634934210)
|
||||
This can trigger a poorly considered lint error in some tools but is included by design.
|
||||
*/
|
||||
|
||||
img,
|
||||
svg,
|
||||
video,
|
||||
canvas,
|
||||
audio,
|
||||
iframe,
|
||||
embed,
|
||||
object {
|
||||
display: block; /* 1 */
|
||||
vertical-align: middle; /* 2 */
|
||||
}
|
||||
|
||||
/*
|
||||
Constrain images and videos to the parent width and preserve their intrinsic aspect ratio. (https://github.com/mozdevs/cssremedy/issues/14)
|
||||
*/
|
||||
|
||||
img,
|
||||
video {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
/*
|
||||
Make elements with the HTML hidden attribute stay hidden by default.
|
||||
*/
|
||||
|
||||
[hidden]:where(:not([hidden="until-found"])) {
|
||||
display: none;
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" height="200" width="200" viewBox="0 0 24 24" class="dark-icon">
|
||||
<path d="M11.01 3.05C6.51 3.54 3 7.36 3 12a9 9 0 0 0 9 9c4.63 0 8.45-3.5 8.95-8c.09-.79-.78-1.42-1.54-.95A5.403 5.403 0 0 1 11.1 7.5c0-1.06.31-2.06.84-2.89c.45-.67-.04-1.63-.93-1.56z" fill="currentColor" /></svg>
|
||||
|
Before Width: | Height: | Size: 527 B After Width: | Height: | Size: 527 B |
@@ -0,0 +1 @@
|
||||
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1768551179331" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="4278" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M549.12 643.008L440.768 535.936l1.28-1.28A747.52 747.52 0 0 0 600.32 256h125.056V170.688H426.688V85.376H341.376v85.312H42.688v84.928h476.608A670.4 670.4 0 0 1 384 484.288a667.52 667.52 0 0 1-98.56-142.912H200.128a749.312 749.312 0 0 0 127.168 194.56L110.08 750.08l60.608 60.544L384 597.376l132.736 132.672 32.384-87.04z m240.256-216.32H704l-192 512h85.376l47.744-128h202.688l48.192 128h85.376l-192-512z m-111.808 298.688l69.12-184.768 69.12 184.768h-138.24z" p-id="4279"></path></svg>
|
||||
|
Before Width: | Height: | Size: 817 B After Width: | Height: | Size: 817 B |
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" height="200" width="200" viewBox="0 0 24 24" class="light-icon">
|
||||
<path
|
||||
d="M6.05 4.14l-.39-.39a.993.993 0 0 0-1.4 0l-.01.01a.984.984 0 0 0 0 1.4l.39.39c.39.39 1.01.39 1.4 0l.01-.01a.984.984 0 0 0 0-1.4zM3.01 10.5H1.99c-.55 0-.99.44-.99.99v.01c0 .55.44.99.99.99H3c.56.01 1-.43 1-.98v-.01c0-.56-.44-1-.99-1zm9-9.95H12c-.56 0-1 .44-1 .99v.96c0 .55.44.99.99.99H12c.56.01 1-.43 1-.98v-.97c0-.55-.44-.99-.99-.99zm7.74 3.21c-.39-.39-1.02-.39-1.41-.01l-.39.39a.984.984 0 0 0 0 1.4l.01.01c.39.39 1.02.39 1.4 0l.39-.39a.984.984 0 0 0 0-1.4zm-1.81 15.1l.39.39a.996.996 0 1 0 1.41-1.41l-.39-.39a.993.993 0 0 0-1.4 0c-.4.4-.4 1.02-.01 1.41zM20 11.49v.01c0 .55.44.99.99.99H22c.55 0 .99-.44.99-.99v-.01c0-.55-.44-.99-.99-.99h-1.01c-.55 0-.99.44-.99.99zM12 5.5c-3.31 0-6 2.69-6 6s2.69 6 6 6s6-2.69 6-6s-2.69-6-6-6zm-.01 16.95H12c.55 0 .99-.44.99-.99v-.96c0-.55-.44-.99-.99-.99h-.01c-.55 0-.99.44-.99.99v.96c0 .55.44.99.99.99zm-7.74-3.21c.39.39 1.02.39 1.41 0l.39-.39a.993.993 0 0 0 0-1.4l-.01-.01a.996.996 0 0 0-1.41 0l-.39.39c-.38.4-.38 1.02.01 1.41z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 1.3 KiB |
@@ -0,0 +1 @@
|
||||
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1768524866566" class="icon" viewBox="0 0 1052 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="1221" xmlns:xlink="http://www.w3.org/1999/xlink" width="205.46875" height="200"><path d="M1017.752 1024H983.04l-69.424-69.424h104.136a34.712 34.712 0 0 1 0 69.424zM983.04 798.373H826.837v69.424L687.989 728.949H983.04V69.424H63.176v34.712L0 40.96A69.424 69.424 0 0 1 63.176 0H983.04a69.424 69.424 0 0 1 69.424 69.424v659.525a69.424 69.424 0 0 1-69.424 69.424zM826.837 607.458h-260.34L219.38 260.339v-69.424h607.458v416.543z" p-id="1222"></path></svg>
|
||||
|
Before Width: | Height: | Size: 699 B After Width: | Height: | Size: 699 B |
@@ -0,0 +1 @@
|
||||
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1768524782127" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="1075" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M850.6368 850.6368l77.3632 0.128v77.2352h-77.3632v-77.3632z m-154.8032 0l77.3632 0.128v77.2352H723.2l-27.3664-27.3664v-49.9968z m77.568-309.6064v77.3376h77.3632v-77.3376h77.2352v232.1664h-232.1664V695.808H618.496v127.4368l-77.3632-77.3632v-204.8768h232.2944z m-309.632 0v127.5392l-127.5648-127.5648h127.5648zM928 76.8v386.9696H541.0304V76.8H928zM463.7696 76.8v386.9696h-204.8l-77.568-77.568h204.8V154.0352H154.0352v204.8L76.8 281.6V76.8h386.9696z m386.9952 77.2352h-232.192v232.192h232.192v-232.192z m-541.9008 77.568v77.3632H231.6032V231.6032h77.2608z m464.3328 0v77.3632H695.808V231.6032h77.3632z" fill="#000000" p-id="1076"></path></svg>
|
||||
|
Before Width: | Height: | Size: 973 B After Width: | Height: | Size: 973 B |
@@ -0,0 +1 @@
|
||||
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1768534060667" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="2308" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M761.61061 291.378506c-9.413135 0-18.204364 3.626784-24.845082 10.262471L459.729123 578.861539 327.677545 446.738513c-6.641725-6.641725-15.432953-10.268509-24.845082-10.268509-9.34068 0-18.132915 3.626784-24.77464 10.268509-6.641725 6.642731-10.268509 15.432953-10.268509 24.77464 0 9.345712 3.626784 18.13694 10.268509 24.778665l156.898673 157.020438c6.641725 6.580339 15.432953 10.268509 24.773634 10.268509 9.35175 0 18.142978-3.68817 24.845082-10.268509l301.809033-302.054575c6.642731-6.576314 10.268509-15.366536 10.268509-24.77464 0-9.346718-3.626784-18.259712-10.268509-24.841057C779.742519 295.00529 770.951291 291.378506 761.61061 291.378506zM773.100794 94.579168l-2.027739 0c-109.742427 0-201.841011-12.483424-259.26476-78.142911-57.366389 65.659488-149.89266 78.142911-260.06378 78.142911C76.277191 94.579168 71.467978 270.042468 71.467978 270.042468c0 98.063054-0.061386 191.573508-0.061386 394.215551 0 113.735512 272.008821 321.109281 420.604332 356.400992 12.726954 3.008903 25.705488 3.008903 38.429422 0.061386 149.70649-35.048181 421.449642-241.31097 421.449642-356.401998L951.889989 270.103854C951.889989 142.22348 889.485952 94.579168 773.100794 94.579168zM888.935494 664.318399c0 65.293187-215.615546 247.458591-353.329697 289.998838-15.800261 4.859529-32.894652 4.859529-48.635539 0C349.131322 911.594845 134.362093 729.422397 134.362093 664.259026l-0.906696-267.93523 0.906696-124.248558c0.366301-11.684404 6.10133-114.54057 117.383429-114.54057 92.832193 0 186.958516-7.930823 260.06378-57.6069 73.039853 49.676076 167.166176 57.6069 261.291492 57.6069 88.778728 0 115.8347 26.3123 115.8347 112.569185L888.935494 664.318399z" p-id="2309"></path></svg>
|
||||
|
Before Width: | Height: | Size: 2.0 KiB After Width: | Height: | Size: 2.0 KiB |
|
Before Width: | Height: | Size: 2.2 KiB After Width: | Height: | Size: 2.2 KiB |
|
Before Width: | Height: | Size: 33 KiB After Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 4.0 KiB After Width: | Height: | Size: 4.0 KiB |
|
Before Width: | Height: | Size: 3.7 KiB After Width: | Height: | Size: 3.7 KiB |
|
Before Width: | Height: | Size: 4.0 KiB After Width: | Height: | Size: 4.0 KiB |
|
Before Width: | Height: | Size: 3.9 KiB After Width: | Height: | Size: 3.9 KiB |
|
Before Width: | Height: | Size: 596 KiB After Width: | Height: | Size: 596 KiB |
|
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 174 KiB After Width: | Height: | Size: 174 KiB |
|
Before Width: | Height: | Size: 4.1 KiB After Width: | Height: | Size: 4.1 KiB |
|
Before Width: | Height: | Size: 6.2 KiB After Width: | Height: | Size: 6.2 KiB |
@@ -0,0 +1,127 @@
|
||||
@use 'sass:map';
|
||||
@use 'mixins' as *;
|
||||
@use 'var' as *;
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
outline: none !important;
|
||||
}
|
||||
|
||||
html,
|
||||
body,
|
||||
#app {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
font-family:
|
||||
Helvetica Neue,
|
||||
Helvetica,
|
||||
PingFang SC,
|
||||
Hiragino Sans GB,
|
||||
Microsoft YaHei,
|
||||
SimSun,
|
||||
sans-serif;
|
||||
font-weight: 400;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
background-color: var(--ma-bg-color);
|
||||
color: var(--el-text-color-primary);
|
||||
font-size: var(--el-font-size-base);
|
||||
}
|
||||
|
||||
.w100 {
|
||||
width: 100% !important;
|
||||
}
|
||||
.h100 {
|
||||
height: 100% !important;
|
||||
}
|
||||
.-center {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* 鼠标置入浮动效果-s */
|
||||
.suspension {
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
.suspension:hover {
|
||||
-webkit-transform: translateY(-4px) scale(1.02);
|
||||
-moz-transform: translateY(-4px) scale(1.02);
|
||||
-ms-transform: translateY(-4px) scale(1.02);
|
||||
-o-transform: translateY(-4px) scale(1.02);
|
||||
transform: translateY(-4px) scale(1.02);
|
||||
-webkit-box-shadow: 0 14px 24px rgba(0, 0, 0, 0.2);
|
||||
box-shadow: 0 14px 24px rgba(0, 0, 0, 0.2);
|
||||
z-index: 999;
|
||||
border-radius: 6px;
|
||||
}
|
||||
/* 鼠标置入浮动效果-e */
|
||||
|
||||
/* 输入组件-s */
|
||||
.block-help {
|
||||
display: block;
|
||||
width: 100%;
|
||||
color: #909399;
|
||||
font-size: 13px;
|
||||
line-height: 16px;
|
||||
padding-top: 5px;
|
||||
}
|
||||
.img-preview-dialog .el-dialog__body {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
img {
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
/* 输入组件-e */
|
||||
|
||||
/* 布局相关-s */
|
||||
.layouts-main {
|
||||
min-height: calc(100vh - 120px);
|
||||
}
|
||||
.user-views {
|
||||
padding: 0 15px;
|
||||
.user-views-card {
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
}
|
||||
.fallback-loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.ma-aside-drawer {
|
||||
.el-drawer__body {
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
/* 布局相关-e */
|
||||
|
||||
/* 暗黑模式公共样式-s */
|
||||
.ma-icon-dark {
|
||||
color: var(--el-text-color-primary) !important;
|
||||
}
|
||||
/* 暗黑模式公共样式-e */
|
||||
|
||||
/* 自适应-s */
|
||||
@media screen and (max-width: 768px) {
|
||||
.xs-hidden {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
/* 自适应-e */
|
||||
|
||||
/* 全局遮罩-s */
|
||||
.ma-layout-shade {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
height: 100vh;
|
||||
width: 100vw;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
z-index: 9999990;
|
||||
}
|
||||
/* 全局遮罩-e */
|
||||
@@ -0,0 +1,25 @@
|
||||
@use 'sass:map';
|
||||
@use 'mixins' as *;
|
||||
|
||||
// Dark theme variables
|
||||
$dark-bg-color: () !default;
|
||||
$dark-bg-color: map.merge(
|
||||
(
|
||||
'': #141414,
|
||||
'overlay': #1d1e1f,
|
||||
),
|
||||
$dark-bg-color
|
||||
);
|
||||
|
||||
$dark-border-color: () !default;
|
||||
$dark-border-color: map.merge(
|
||||
(
|
||||
'': #58585b,
|
||||
),
|
||||
$dark-border-color
|
||||
);
|
||||
|
||||
html.dark {
|
||||
@include set-component-css-var('bg-color', $dark-bg-color);
|
||||
@include set-component-css-var('border-color', $dark-border-color);
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
.el-menu {
|
||||
user-select: none;
|
||||
.el-sub-menu__title:hover {
|
||||
background-color: var(--el-color-primary-light-9) !important;
|
||||
}
|
||||
}
|
||||
|
||||
.el-table {
|
||||
--el-table-border-color: var(--ba-border-color);
|
||||
}
|
||||
|
||||
.el-card {
|
||||
border: none;
|
||||
.el-card__header {
|
||||
border-bottom: 1px solid var(--el-border-color-extra-light);
|
||||
}
|
||||
}
|
||||
|
||||
.el-divider__text.is-center {
|
||||
transform: translateX(-50%) translateY(-62%);
|
||||
}
|
||||
|
||||
/* 修复 Chrome 浏览器输入框内选中字符行高异常的问题开始 <<< */
|
||||
.el-input {
|
||||
.el-input__inner {
|
||||
line-height: calc(var(--el-input-height, 40px) - 4px);
|
||||
}
|
||||
}
|
||||
/* 修复 Chrome 浏览器输入框内选中字符行高异常的问题结束 >>> */
|
||||
|
||||
/* 输入框样式统一开始 <<< */
|
||||
.el-input-number.is-controls-right {
|
||||
.el-input__wrapper {
|
||||
padding-left: 11px;
|
||||
}
|
||||
.el-input__inner {
|
||||
text-align: left;
|
||||
}
|
||||
}
|
||||
.el-textarea__inner {
|
||||
padding: 5px 11px;
|
||||
}
|
||||
.datetime-picker {
|
||||
height: 32px;
|
||||
padding-top: 0;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
.w100 .el-input__wrapper {
|
||||
width: 100%;
|
||||
}
|
||||
/* 输入框样式统一结束 >>> */
|
||||
|
||||
/* dialog 滚动条样式优化开始 <<< */
|
||||
.el-overlay-dialog,
|
||||
.ma-scroll-style {
|
||||
&::-webkit-scrollbar {
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
}
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: #eaeaea;
|
||||
border-radius: var(--el-border-radius-base);
|
||||
box-shadow: none;
|
||||
-webkit-box-shadow: none;
|
||||
}
|
||||
&:hover {
|
||||
&::-webkit-scrollbar-thumb:hover {
|
||||
background: #c8c9cc;
|
||||
}
|
||||
}
|
||||
}
|
||||
@supports not (selector(::-webkit-scrollbar)) {
|
||||
.el-overlay-dialog,
|
||||
.ba-scroll-style {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: #c8c9cc #eaeaea;
|
||||
}
|
||||
}
|
||||
/* dialog 滚动条样式优化结束 >>> */
|
||||
|
||||
/* 小屏设备 el-radio-group 样式优化开始 <<< */
|
||||
.ma-input-item-radio {
|
||||
margin-bottom: 10px;
|
||||
.el-radio-group {
|
||||
.el-radio {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
}
|
||||
}
|
||||
/* 小屏设备 el-radio-group 样式优化结束 >>> */
|
||||
@@ -0,0 +1,6 @@
|
||||
@forward 'element-plus/theme-chalk/src/dark/var.scss' with (
|
||||
$bg-color: (
|
||||
'page': #0a0a0a,
|
||||
'overlay': #1d1e1f,
|
||||
)
|
||||
);
|
||||
@@ -0,0 +1,26 @@
|
||||
$-colors: (
|
||||
'primary': (
|
||||
'base': rgba(107,33,168, 1),
|
||||
),
|
||||
'success': (
|
||||
'base': green,
|
||||
),
|
||||
'warning': (
|
||||
'base': #f9a23c,
|
||||
),
|
||||
'danger': (
|
||||
'base': #ff3300,
|
||||
),
|
||||
'error': (
|
||||
'base': #f56c6c,
|
||||
),
|
||||
'info': (
|
||||
'base': #909399,
|
||||
),
|
||||
);
|
||||
|
||||
@forward 'element-plus/theme-chalk/src/common/var.scss' with (
|
||||
$colors: $-colors
|
||||
);
|
||||
|
||||
@use './dark.scss';
|
||||
@@ -0,0 +1,4 @@
|
||||
@forward 'app';
|
||||
@forward 'dark';
|
||||
@forward 'element';
|
||||
@forward 'var';
|
||||
@@ -0,0 +1,30 @@
|
||||
@mixin set-css-var-value($name, $value) {
|
||||
#{joinVarName($name)}: #{$value};
|
||||
}
|
||||
|
||||
@function joinVarName($list) {
|
||||
$name: '--ma';
|
||||
@each $item in $list {
|
||||
@if $item != '' {
|
||||
$name: $name + '-' + $item;
|
||||
}
|
||||
}
|
||||
@return $name;
|
||||
}
|
||||
|
||||
@function getCssVarName($args...) {
|
||||
@return joinVarName($args);
|
||||
}
|
||||
|
||||
/*
|
||||
* 通过映射设置所有的CSS变量
|
||||
*/
|
||||
@mixin set-component-css-var($name, $variables) {
|
||||
@each $attribute, $value in $variables {
|
||||
@if $attribute == 'default' {
|
||||
#{getCssVarName($name)}: #{$value};
|
||||
} @else {
|
||||
#{getCssVarName($name, $attribute)}: #{$value};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
@use 'sass:map';
|
||||
@use 'mixins' as *;
|
||||
|
||||
// Light theme variables
|
||||
$light-bg-color: () !default;
|
||||
$light-bg-color: map.merge(
|
||||
(
|
||||
'': #f5f5f5,
|
||||
'overlay': #ffffff,
|
||||
),
|
||||
$light-bg-color
|
||||
);
|
||||
|
||||
$light-border-color: () !default;
|
||||
$light-border-color: map.merge(
|
||||
(
|
||||
'': #f6f6f6,
|
||||
),
|
||||
$light-border-color
|
||||
);
|
||||
|
||||
:root {
|
||||
@include set-component-css-var('bg-color', $light-bg-color);
|
||||
@include set-component-css-var('border-color', $light-border-color);
|
||||
}
|
||||
@@ -0,0 +1,400 @@
|
||||
<template>
|
||||
<transition name="ad-fade">
|
||||
<div v-if="!isHidden && displayedAds.length > 0" class="advertisement-wrapper" :class="[`ad-${type}`, position && type === 'float' ? `ad-position-${position}` : '']">
|
||||
<div
|
||||
v-for="ad in displayedAds"
|
||||
:key="ad.id"
|
||||
class="ad-item"
|
||||
>
|
||||
<!-- 简单模式 -->
|
||||
<component
|
||||
v-if="type === 'simple'"
|
||||
:is="SimpleModeRaw"
|
||||
:ad="ad"
|
||||
@click="handleClick"
|
||||
/>
|
||||
|
||||
<!-- 悬浮模式 -->
|
||||
<component
|
||||
v-else-if="type === 'float'"
|
||||
:is="FloatModeRaw"
|
||||
:ad="ad"
|
||||
:closable="closable"
|
||||
@click="handleClick"
|
||||
@close="handleClose"
|
||||
/>
|
||||
|
||||
<!-- 自定义扩展模式 -->
|
||||
<component
|
||||
v-else-if="customModeComponent"
|
||||
:is="customModeComponent"
|
||||
:ad="ad"
|
||||
v-bind="customModeProps"
|
||||
@click="handleClick"
|
||||
@close="handleClose"
|
||||
/>
|
||||
|
||||
<!-- 默认模式(向后兼容) -->
|
||||
<div
|
||||
v-else
|
||||
class="ad-card"
|
||||
@click="handleClick(ad)"
|
||||
>
|
||||
<div class="ad-image-wrapper">
|
||||
<img
|
||||
:src="ad.image"
|
||||
:alt="ad.title"
|
||||
class="ad-image"
|
||||
loading="lazy"
|
||||
/>
|
||||
<div class="ad-gradient"></div>
|
||||
<div v-if="showTitle" class="ad-content">
|
||||
<h3 class="ad-title">{{ ad.title }}</h3>
|
||||
<p v-if="ad.description" class="ad-description">{{ ad.description }}</p>
|
||||
<span class="ad-badge">广告</span>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
v-if="closable"
|
||||
class="ad-close-btn"
|
||||
@click.stop="handleClose(ad)"
|
||||
title="关闭"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M18 6L6 18M6 6l12 12"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, computed, markRaw, shallowRef } from 'vue'
|
||||
import type { Component } from 'vue'
|
||||
import { getAdvertisements } from '~/api/site'
|
||||
import type { AdvertisementItem } from '~/api/site/types'
|
||||
import SimpleMode from './modes/simple.vue'
|
||||
import FloatMode from './modes/float.vue'
|
||||
import { getModeComponent, registerMode } from './modes'
|
||||
|
||||
interface Props {
|
||||
type?: string // 简化类型定义,支持字符串类型
|
||||
offset?: number
|
||||
limit?: number
|
||||
showTitle?: boolean
|
||||
closable?: boolean
|
||||
showTodayBtn?: boolean
|
||||
autoCloseDelay?: number
|
||||
position?: 'top' | 'center' | 'bottom'
|
||||
customModeProps?: Record<string, any>
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
type: 'simple',
|
||||
offset: 0,
|
||||
limit: 0,
|
||||
showTitle: false,
|
||||
closable: false,
|
||||
showTodayBtn: false,
|
||||
autoCloseDelay: 0,
|
||||
position: 'center',
|
||||
customModeProps: () => ({})
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
click: [ad: AdvertisementItem]
|
||||
close: [ad: AdvertisementItem]
|
||||
}>()
|
||||
|
||||
// 将组件标记为原始组件,避免响应式代理
|
||||
const SimpleModeRaw = markRaw(SimpleMode)
|
||||
const FloatModeRaw = markRaw(FloatMode)
|
||||
|
||||
// 使用这些标记为原始的组件
|
||||
|
||||
const ads = ref<AdvertisementItem[]>([])
|
||||
const isHidden = ref(false)
|
||||
const customModeComponent = shallowRef<Component | null>(null)
|
||||
|
||||
const COMPONENT_STORAGE_KEY = `ad_component_${props.type}_${props.offset}`
|
||||
|
||||
// 动态获取自定义模式组件
|
||||
onMounted(() => {
|
||||
if (!['simple', 'float'].includes(props.type!)) {
|
||||
const component = getModeComponent(props.type!)
|
||||
if (component) {
|
||||
customModeComponent.value = markRaw(component)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const checkTodayClosed = () => {
|
||||
const today = new Date().toDateString()
|
||||
const stored = localStorage.getItem(`ad_today_${COMPONENT_STORAGE_KEY}`)
|
||||
return stored === today
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
if (checkTodayClosed()) {
|
||||
isHidden.value = true
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
ads.value = await getAdvertisements()
|
||||
} catch (error) {
|
||||
console.error('获取广告失败:', error)
|
||||
}
|
||||
})
|
||||
|
||||
const displayedAds = computed(() => {
|
||||
const now = Date.now() / 1000
|
||||
const validAds = ads.value.filter(
|
||||
ad =>
|
||||
ad.enabled === 1 &&
|
||||
ad.start_time <= now &&
|
||||
ad.end_time >= now
|
||||
)
|
||||
const slicedAds = validAds.slice(props.offset)
|
||||
const result = props.limit > 0 ? slicedAds.slice(0, props.limit) : slicedAds
|
||||
|
||||
return result
|
||||
})
|
||||
|
||||
const handleClick = (ad: AdvertisementItem) => {
|
||||
emit('click', ad)
|
||||
if (ad.link) {
|
||||
window.open(ad.link, '_blank')
|
||||
}
|
||||
}
|
||||
|
||||
const handleClose = (ad: AdvertisementItem) => {
|
||||
isHidden.value = true
|
||||
emit('close', ad)
|
||||
}
|
||||
|
||||
// 导出注册方法供外部使用
|
||||
defineExpose({
|
||||
registerMode
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
// 基础广告卡片
|
||||
.advertisement-wrapper {
|
||||
width: 100%;
|
||||
|
||||
&.ad-simple {
|
||||
display: block;
|
||||
}
|
||||
|
||||
&.ad-float {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
.ad-item {
|
||||
display: block;
|
||||
}
|
||||
|
||||
// 悬浮广告定位
|
||||
.ad-float {
|
||||
position: fixed;
|
||||
right: 24px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
z-index: 100;
|
||||
width: 200px;
|
||||
|
||||
.ad-item {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
// 右上位置
|
||||
.ad-float.ad-position-top {
|
||||
top: 24px;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
// 右中位置(默认)
|
||||
.ad-float.ad-position-center {
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
|
||||
// 右下位置
|
||||
.ad-float.ad-position-bottom {
|
||||
top: auto;
|
||||
bottom: 24px;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
// 默认卡片样式(向后兼容)
|
||||
.ad-card {
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
overflow: hidden;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
box-shadow:
|
||||
0 4px 6px rgba(0, 0, 0, 0.07),
|
||||
0 10px 20px rgba(0, 0, 0, 0.08);
|
||||
transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
|
||||
&:hover {
|
||||
transform: none;
|
||||
box-shadow:
|
||||
0 8px 12px rgba(102, 126, 234, 0.2),
|
||||
0 20px 40px rgba(0, 0, 0, 0.15);
|
||||
|
||||
.ad-close-btn {
|
||||
opacity: 1;
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
}
|
||||
|
||||
.ad-image-wrapper {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.ad-image {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
display: block;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.ad-gradient {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 60%;
|
||||
background: linear-gradient(
|
||||
to top,
|
||||
rgba(0, 0, 0, 0.8) 0%,
|
||||
rgba(0, 0, 0, 0.4) 50%,
|
||||
transparent 100%
|
||||
);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.ad-content {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
padding: 24px;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.ad-title {
|
||||
color: #fff;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.ad-description {
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
font-size: 14px;
|
||||
margin: 0 0 12px;
|
||||
line-height: 1.5;
|
||||
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.ad-badge {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.ad-close-btn {
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
right: 12px;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
padding: 0;
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #666;
|
||||
opacity: 0;
|
||||
transform: rotate(0deg);
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
backdrop-filter: blur(10px);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
|
||||
svg {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: #fff;
|
||||
color: #333;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 动画
|
||||
.ad-fade-enter-active,
|
||||
.ad-fade-leave-active {
|
||||
transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.ad-fade-enter-from,
|
||||
.ad-fade-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
|
||||
// 响应式
|
||||
@media (max-width: 768px) {
|
||||
.ad-float {
|
||||
right: 12px;
|
||||
width: 160px;
|
||||
|
||||
.ad-card {
|
||||
.ad-title {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.ad-description {
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.ad-card {
|
||||
&:hover {
|
||||
transform: none;
|
||||
box-shadow: 0 8px 12px rgba(102, 126, 234, 0.2);
|
||||
}
|
||||
|
||||
.ad-close-btn {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.ad-title {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.ad-description {
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,137 @@
|
||||
<template>
|
||||
<div class="ad-float-card" @click="handleClick">
|
||||
<div class="ad-image-wrapper">
|
||||
<img :src="ad.image" :alt="ad.title" class="ad-image" />
|
||||
</div>
|
||||
<div class="ad-content">
|
||||
<h3 class="ad-title">{{ ad.title }}</h3>
|
||||
<p v-if="ad.description" class="ad-description">{{ ad.description }}</p>
|
||||
<span class="ad-badge">广告</span>
|
||||
</div>
|
||||
<button
|
||||
v-if="closable"
|
||||
class="ad-close-btn"
|
||||
@click.stop="handleClose"
|
||||
title="关闭"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M18 6L6 18M6 6l12 12"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { AdvertisementItem } from '~/api/site/types'
|
||||
|
||||
interface Props {
|
||||
ad: AdvertisementItem
|
||||
closable?: boolean
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
closable: false
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
click: [ad: AdvertisementItem]
|
||||
close: [ad: AdvertisementItem]
|
||||
}>()
|
||||
|
||||
const handleClick = () => {
|
||||
emit('click', props.ad)
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
emit('close', props.ad)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.ad-float-card {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
background: #fff;
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
|
||||
cursor: pointer;
|
||||
|
||||
.ad-image-wrapper {
|
||||
width: 100%;
|
||||
border-radius: 1px 1px 0 0;
|
||||
}
|
||||
|
||||
.ad-image {
|
||||
width: 100%;
|
||||
height: 120px;
|
||||
object-fit: contain;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.ad-content {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.ad-title {
|
||||
color: #1a1a1a;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
line-height: 1.4;
|
||||
margin: 0 0 8px;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.ad-description {
|
||||
color: #666;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
margin: 0 0 10px;
|
||||
}
|
||||
|
||||
.ad-badge {
|
||||
display: inline-block;
|
||||
padding: 3px 6px;
|
||||
background: #f5f5f5;
|
||||
color: #999;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
border-radius: 3px;
|
||||
margin-top: 8px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.ad-close-btn {
|
||||
position: absolute;
|
||||
bottom: 6px;
|
||||
right: 6px;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
background: transparent;
|
||||
border-radius: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: rgba(0, 0, 0, 0.6);
|
||||
opacity: 1;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
svg {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
color: rgba(0, 0, 0, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { Component } from 'vue'
|
||||
|
||||
// 内置模式
|
||||
import SimpleMode from './simple.vue'
|
||||
import FloatMode from './float.vue'
|
||||
|
||||
// 动态加载自定义模式
|
||||
const customModes: Record<string, Component> = {
|
||||
simple: SimpleMode,
|
||||
float: FloatMode
|
||||
}
|
||||
|
||||
// 注册自定义模式
|
||||
export const registerMode = (name: string, component: Component) => {
|
||||
customModes[name] = component
|
||||
}
|
||||
|
||||
// 获取模式组件
|
||||
export const getModeComponent = (modeName: string): Component | undefined => {
|
||||
return customModes[modeName]
|
||||
}
|
||||
|
||||
// 获取所有可用模式
|
||||
export const getAvailableModes = (): string[] => {
|
||||
return Object.keys(customModes)
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
<template>
|
||||
<div class="ad-simple-layout" @click="handleClick">
|
||||
<img :src="ad.image" :alt="ad.title" class="ad-simple-image" />
|
||||
<!-- <span class="ad-badge">广告</span> -->
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { AdvertisementItem } from '~/api/site/types'
|
||||
|
||||
interface Props {
|
||||
ad: AdvertisementItem
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
click: [ad: AdvertisementItem]
|
||||
}>()
|
||||
|
||||
const handleClick = () => {
|
||||
emit('click', props.ad)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.ad-simple-layout {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 50px;
|
||||
margin: 4px 0;
|
||||
overflow: hidden;
|
||||
background: transparent;
|
||||
border-radius: 1px;
|
||||
cursor: pointer;
|
||||
transition: box-shadow 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
background-color: rgb(218, 225, 225);
|
||||
}
|
||||
|
||||
.ad-simple-image {
|
||||
width: 100%;
|
||||
height: 50px;
|
||||
object-fit: contain;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.ad-badge {
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
right: 6px;
|
||||
padding: 2px 6px;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
color: #fff;
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
border-radius: 2px;
|
||||
line-height: 1;
|
||||
z-index: 1;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,77 @@
|
||||
<template>
|
||||
<div v-if="shouldLoadAnalytics">
|
||||
<!-- 百度统计代码将被注入到这里 -->
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, watch } from 'vue'
|
||||
import { useConfigStore } from '~/stores/config'
|
||||
|
||||
const configStore = useConfigStore()
|
||||
const shouldLoadAnalytics = ref(false)
|
||||
const analyticsLoaded = ref(false)
|
||||
|
||||
// 加载百度统计脚本
|
||||
const loadBaiduAnalytics = () => {
|
||||
if (!configStore.analytics.baidu_tongji_enabled || !configStore.analytics.baidu_tongji_code) {
|
||||
return
|
||||
}
|
||||
|
||||
if (analyticsLoaded.value) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
// 创建百度统计脚本
|
||||
const script = document.createElement('script')
|
||||
script.text = `
|
||||
var _hmt = _hmt || [];
|
||||
(function() {
|
||||
var hm = document.createElement("script");
|
||||
hm.src = "https://hm.baidu.com/hm.js?${configStore.analytics.baidu_tongji_code}";
|
||||
var s = document.getElementsByTagName("script")[0];
|
||||
s.parentNode.insertBefore(hm, s);
|
||||
})();
|
||||
`
|
||||
document.head.appendChild(script)
|
||||
analyticsLoaded.value = true
|
||||
console.log('百度统计已加载')
|
||||
} catch (error) {
|
||||
console.error('百度统计加载失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 初始化 - 配置已在 app.vue 中统一加载,这里直接使用
|
||||
onMounted(() => {
|
||||
// 检查是否应该加载统计
|
||||
shouldLoadAnalytics.value = configStore.analytics.baidu_tongji_enabled === 1 && !!configStore.analytics.baidu_tongji_code
|
||||
|
||||
// 加载统计脚本
|
||||
if (shouldLoadAnalytics.value) {
|
||||
loadBaiduAnalytics()
|
||||
}
|
||||
})
|
||||
|
||||
// 监听配置变化
|
||||
watch(
|
||||
() => [
|
||||
configStore.analytics.baidu_tongji_enabled,
|
||||
configStore.analytics.baidu_tongji_code
|
||||
],
|
||||
() => {
|
||||
shouldLoadAnalytics.value = configStore.analytics.baidu_tongji_enabled === 1 && !!configStore.analytics.baidu_tongji_code
|
||||
if (shouldLoadAnalytics.value && !analyticsLoaded.value) {
|
||||
loadBaiduAnalytics()
|
||||
}
|
||||
},
|
||||
{ deep: true }
|
||||
)
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
// 这是一个无界面的组件,主要用于加载百度统计
|
||||
export default {
|
||||
name: 'BaiduAnalytics'
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,299 @@
|
||||
<template>
|
||||
<div class="icon-selector">
|
||||
<div
|
||||
class="select"
|
||||
@click="handleClick"
|
||||
:style="{ width: props.width }"
|
||||
:class="[size, { 'is-disabled': disabled }, { 'has-icon': selectValue }]"
|
||||
>
|
||||
<div class="icon">
|
||||
<Icon v-show="selectValue" :icon="selectValue" />
|
||||
</div>
|
||||
<div class="text">{{ props.text }}</div>
|
||||
<div class="arrow">
|
||||
<Icon icon="ant-design:down-outlined" class="arrow-icon" />
|
||||
<Icon icon="ant-design:close-outlined" @click.stop="clearIcon" class="clear-icon" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-dialog title="选择图标" width="40%" v-model="visible" align-center>
|
||||
<el-tabs v-model="activeTab">
|
||||
<el-tab-pane v-for="prefix in iconifyPrefixs" :key="prefix" :label="prefix" :name="prefix">
|
||||
<el-scrollbar height="400px">
|
||||
<el-input v-model="searchText" placeholder="请输入图标名称"></el-input>
|
||||
<ul class="icons-list" style="margin-top: 10px">
|
||||
<li
|
||||
v-for="icon in paginatedIconifyList"
|
||||
:key="icon"
|
||||
@click="selectorIconifyIcon(icon)"
|
||||
>
|
||||
<i class="iconfont-sys">
|
||||
<Icon :icon="icon" />
|
||||
</i>
|
||||
</li>
|
||||
</ul>
|
||||
<el-pagination
|
||||
v-if="filteredIconifyList.length > iconifyPageSize"
|
||||
layout="prev, pager, next"
|
||||
:total="filteredIconifyList.length"
|
||||
:page-size="iconifyPageSize"
|
||||
v-model:current-page="iconifyCurrentPage"
|
||||
size="small"
|
||||
style="margin-top: 10px; justify-content: center"
|
||||
/>
|
||||
</el-scrollbar>
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="Svg图标" name="svg">
|
||||
<el-scrollbar height="400px">
|
||||
<ul class="icons-list">
|
||||
<li v-for="icon in svgList" :key="icon" @click="selectorIconifyIcon(icon)">
|
||||
<i class="iconfont-sys">
|
||||
<Icon :icon="icon" />
|
||||
</i>
|
||||
</li>
|
||||
</ul>
|
||||
</el-scrollbar>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
<template #footer>
|
||||
<span class="dialog-footer">
|
||||
<el-button @click="visible = false">取 消</el-button>
|
||||
<el-button type="primary" @click="visible = false">确 定</el-button>
|
||||
</span>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { listIcons } from '@iconify/vue'
|
||||
import { fetchIconsData } from '~/utils/icon-utils'
|
||||
import Icon from './iconify.vue'
|
||||
|
||||
defineOptions({ name: 'ArtIconSelector' })
|
||||
|
||||
const searchText = ref('')
|
||||
const activeTab = ref('ant-design')
|
||||
const iconifyCurrentPage = ref(1)
|
||||
const iconifyPageSize = ref(60)
|
||||
|
||||
const svgList = computed(() => {
|
||||
return listIcons('', 'svg')
|
||||
})
|
||||
|
||||
const iconifyMap = reactive<any>({})
|
||||
const iconifyPrefixs = ['ant-design', 'carbon', 'ic', 'lucide', 'ep', 'ri']
|
||||
|
||||
const iconifyList = computed(() => {
|
||||
return iconifyMap[activeTab.value]
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
iconifyPrefixs.forEach((prefix: any) => {
|
||||
fetchIconsData(prefix).then((icons) => {
|
||||
iconifyMap[prefix] = icons
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
watchDebounced(
|
||||
() => activeTab.value,
|
||||
() => {
|
||||
if (iconifyPrefixs.includes(activeTab.value)) {
|
||||
fetchIconsData(activeTab.value).then((icons) => {
|
||||
iconifyMap[activeTab.value] = icons
|
||||
})
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
const filteredIconifyList = computed<string[]>(() => {
|
||||
if (!searchText.value) {
|
||||
return iconifyList.value || []
|
||||
}
|
||||
return (iconifyList.value || []).filter((icon: any) => icon.includes(searchText.value))
|
||||
})
|
||||
|
||||
const paginatedIconifyList = computed<string[]>(() => {
|
||||
const start = (iconifyCurrentPage.value - 1) * iconifyPageSize.value
|
||||
const end = start + iconifyPageSize.value
|
||||
return filteredIconifyList.value.slice(start, end)
|
||||
})
|
||||
|
||||
type ComponentSize = 'large' | 'default' | 'small'
|
||||
|
||||
interface Props {
|
||||
iconType?: any
|
||||
modelValue?: string
|
||||
text?: string
|
||||
width?: string
|
||||
size?: ComponentSize
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
'update:modelValue': [value: string]
|
||||
getIcon: [value: string]
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
iconType: '',
|
||||
modelValue: '',
|
||||
text: '图标选择器',
|
||||
width: '200px',
|
||||
size: 'default',
|
||||
disabled: false
|
||||
})
|
||||
|
||||
const emits = defineEmits<Emits>()
|
||||
|
||||
const selectValue = ref<string>(props.modelValue)
|
||||
const visible = ref<boolean>(false)
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(newVal: string) => {
|
||||
selectValue.value = newVal
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
watch(searchText, () => {
|
||||
iconifyCurrentPage.value = 1
|
||||
})
|
||||
|
||||
const selectorIconifyIcon = (icon: string): void => {
|
||||
selectValue.value = icon
|
||||
visible.value = false
|
||||
emits('update:modelValue', icon)
|
||||
emits('getIcon', icon)
|
||||
}
|
||||
|
||||
const handleClick = () => {
|
||||
if (!props.disabled) {
|
||||
visible.value = true
|
||||
}
|
||||
}
|
||||
|
||||
const clearIcon = () => {
|
||||
selectValue.value = ''
|
||||
emits('update:modelValue', '')
|
||||
emits('getIcon', '')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.icon-selector {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.select {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 12px;
|
||||
border: 1px solid var(--el-border-color);
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
background: var(--el-fill-color-blank);
|
||||
|
||||
&:hover {
|
||||
border-color: var(--el-color-primary);
|
||||
}
|
||||
|
||||
&.is-disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.6;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--el-border-color);
|
||||
}
|
||||
}
|
||||
|
||||
&.has-icon {
|
||||
.text {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
&.large {
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
&.default {
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
&.small {
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
.icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.text {
|
||||
flex: 1;
|
||||
font-size: 14px;
|
||||
color: var(--el-text-color-regular);
|
||||
}
|
||||
|
||||
.arrow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
color: var(--el-text-color-placeholder);
|
||||
|
||||
.arrow-icon {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.clear-icon {
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
color: var(--el-color-danger);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.icons-list {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(100px, 1fr));
|
||||
gap: 8px;
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
|
||||
li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 12px;
|
||||
border: 1px solid var(--el-border-color);
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--el-color-primary);
|
||||
background: var(--el-color-primary-light-9);
|
||||
}
|
||||
|
||||
.iconfont-sys {
|
||||
font-size: 24px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.dialog-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,141 @@
|
||||
# 图标组件使用说明
|
||||
|
||||
## 功能特性
|
||||
|
||||
- 支持多种图标库:Ant Design、Element Plus、Carbon、IC、Lucide、Ri 等
|
||||
- 支持自定义图标
|
||||
- 支持图标选择器组件
|
||||
- 支持离线模式
|
||||
|
||||
## 安装依赖
|
||||
|
||||
```bash
|
||||
pnpm add @iconify/vue @iconify/json
|
||||
```
|
||||
|
||||
## 使用方式
|
||||
|
||||
### 1. 基础图标组件 (Iconify)
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<Iconify icon="ant-design:home-outlined" />
|
||||
<Iconify icon="ep:home-filled" />
|
||||
<Iconify icon="carbon:home" />
|
||||
<Iconify icon="ic:baseline-home" />
|
||||
<Iconify icon="lucide:home" />
|
||||
<Iconify icon="ri:home-line" />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Iconify from '~/components/iconify.vue'
|
||||
</script>
|
||||
```
|
||||
|
||||
### 2. 自定义尺寸和颜色
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<Iconify icon="ant-design:home-outlined" width="24" height="24" />
|
||||
<Iconify icon="ant-design:home-outlined" width="32" height="32" color="#409eff" />
|
||||
<Iconify icon="ant-design:home-outlined" size="1.5em" color="#67c23a" />
|
||||
</template>
|
||||
```
|
||||
|
||||
### 3. 图标选择器 (IconSelector)
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<IconSelector v-model="selectedIcon" text="选择图标" width="300px" />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import IconSelector from '~/components/icon-selector.vue'
|
||||
|
||||
const selectedIcon = ref('')
|
||||
</script>
|
||||
```
|
||||
|
||||
### 4. 监听图标选择事件
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<IconSelector v-model="selectedIcon" @get-icon="handleGetIcon" />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import IconSelector from '~/components/icon-selector.vue'
|
||||
|
||||
const selectedIcon = ref('')
|
||||
|
||||
const handleGetIcon = (icon: string) => {
|
||||
console.log('选择的图标:', icon)
|
||||
}
|
||||
</script>
|
||||
```
|
||||
|
||||
## 支持的图标库
|
||||
|
||||
| 图标库 | 前缀 | 示例 |
|
||||
|--------|------|------|
|
||||
| Ant Design | `ant-design` | `ant-design:home-outlined` |
|
||||
| Element Plus | `ep` | `ep:home-filled` |
|
||||
| Carbon | `carbon` | `carbon:home` |
|
||||
| IC | `ic` | `ic:baseline-home` |
|
||||
| Lucide | `lucide` | `lucide:home` |
|
||||
| Ri | `ri` | `ri:home-line` |
|
||||
|
||||
## 自定义添加图标库
|
||||
|
||||
### 1. 安装图标库
|
||||
|
||||
```bash
|
||||
pnpm add @iconify-json/your-icon-library
|
||||
```
|
||||
|
||||
### 2. 在 load-icon.ts 中添加
|
||||
|
||||
```typescript
|
||||
import YourIcons from '@iconify/json/json/your-icon-library.json'
|
||||
addCollection(YourIcons)
|
||||
```
|
||||
|
||||
### 3. 在 icon-selector.vue 中添加
|
||||
|
||||
```typescript
|
||||
const iconifyPrefixs = ['ant-design', 'carbon', 'ic', 'lucide', 'ep', 'ri', 'your-icon-library']
|
||||
```
|
||||
|
||||
## 配置说明
|
||||
|
||||
### 离线模式
|
||||
|
||||
在 `.env` 文件中配置:
|
||||
|
||||
```env
|
||||
VITE_APP_ICON_OFFLINE=true
|
||||
VITE_APP_ICONIFY_PREFIX=ant-design,carbon,ic,lucide,ep,ri
|
||||
```
|
||||
|
||||
### 图标选择器配置
|
||||
|
||||
| 属性 | 类型 | 默认值 | 说明 |
|
||||
|------|------|--------|------|
|
||||
| modelValue | string | '' | v-model 绑定的图标值 |
|
||||
| text | string | '图标选择器' | 显示文本 |
|
||||
| width | string | '200px' | 组件宽度 |
|
||||
| size | string | 'default' | 组件大小:large/default/small |
|
||||
| disabled | boolean | false | 是否禁用 |
|
||||
|
||||
## 注意事项
|
||||
|
||||
1. 图标名称格式:`图标库名称:图标名称`
|
||||
2. 离线模式下需要预先加载所有图标数据
|
||||
3. 图标选择器支持搜索功能
|
||||
4. 图标选择器支持分页显示
|
||||
|
||||
## 示例代码
|
||||
|
||||
完整示例请参考项目中的使用场景。
|
||||
@@ -0,0 +1,49 @@
|
||||
<template>
|
||||
<i class="iconfont-sys" v-if="iconType === 'unicode'" v-html="icon"></i>
|
||||
<i :class="`iconfont-sys ${icon}`" v-else-if="iconType === 'className'"></i>
|
||||
<ElIcon class="m-icon" v-else-if="typeof icon === 'string'">
|
||||
<Icon :icon="icon" :width="width" :height="height" :color="color"></Icon>
|
||||
</ElIcon>
|
||||
<ElIcon class="m-icon" v-else-if="typeof icon === 'object'"
|
||||
><component :is="icon" :width="width" :height="height" :color="color"></component
|
||||
></ElIcon>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Icon } from '@iconify/vue'
|
||||
import type { VNode } from 'vue'
|
||||
import { ElIcon } from 'element-plus'
|
||||
|
||||
const props = defineProps({
|
||||
icon: {
|
||||
type: [String, Object] as PropType<string | VNode>
|
||||
},
|
||||
width: {
|
||||
type: [String, Number],
|
||||
default: '1em'
|
||||
},
|
||||
height: {
|
||||
type: [String, Number],
|
||||
default: '1em'
|
||||
},
|
||||
color: {
|
||||
type: String,
|
||||
default: undefined
|
||||
}
|
||||
})
|
||||
|
||||
const iconType = computed(() => {
|
||||
if (props.icon?.toString().startsWith('iconsys-')) {
|
||||
return 'className'
|
||||
} else if (props.icon?.toString().includes(':')) {
|
||||
return 'iconify'
|
||||
}
|
||||
return 'unicode'
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.m-icon {
|
||||
width: auto !important;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,3 @@
|
||||
export { default as Icon } from './iconify.vue'
|
||||
export { default as IconSelector } from './icon-selector.vue'
|
||||
export { default } from './iconify.vue'
|
||||
@@ -0,0 +1,565 @@
|
||||
<template>
|
||||
<div class="forget-password-container" :class="{'dark-mode': isDarkMode}">
|
||||
<div class="forget-password-form-container">
|
||||
<!-- 第一步:邮箱验证 -->
|
||||
<div v-if="currentStep === 1">
|
||||
<div class="forget-password-title">{{ t('auth.forget.password.title') }}</div>
|
||||
|
||||
<el-form
|
||||
:model="step1Data"
|
||||
ref="step1FormRef"
|
||||
:rules="step1Rules"
|
||||
:validate-on-rule-change="false"
|
||||
class="forget-password-form"
|
||||
>
|
||||
<el-form-item prop="email" class="form-item">
|
||||
<div class="input-container">
|
||||
<el-input
|
||||
v-model="step1Data.email"
|
||||
:placeholder="t('auth.forget.password.email_placeholder')"
|
||||
clearable
|
||||
class="forget-password-input"
|
||||
>
|
||||
<template #prefix>
|
||||
<Icon
|
||||
icon="ant-design:mail-outlined"
|
||||
:color="isDarkMode ? '#ffffff' : '#000000'"
|
||||
class="input-prefix-icon"
|
||||
/>
|
||||
</template>
|
||||
</el-input>
|
||||
</div>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item prop="captcha_code" class="form-item">
|
||||
<div class="input-container">
|
||||
<el-input
|
||||
v-model="step1Data.captcha_code"
|
||||
:placeholder="t('auth.forget.password.captcha_placeholder')"
|
||||
class="forget-password-input"
|
||||
>
|
||||
<template #prefix>
|
||||
<Icon
|
||||
icon="ant-design:safety-outlined"
|
||||
:color="isDarkMode ? '#ffffff' : '#000000'"
|
||||
class="input-prefix-icon"
|
||||
/>
|
||||
</template>
|
||||
<template #suffix>
|
||||
<div class="captcha-image" @click="captcha.refresh()">
|
||||
<img v-if="captcha.image.value" :src="captcha.image.value" :alt="t('auth.forget.password.captcha_alt')" />
|
||||
<Icon v-else icon="ant-design:loading-outlined" :size="24" />
|
||||
</div>
|
||||
</template>
|
||||
</el-input>
|
||||
</div>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item class="form-item">
|
||||
<el-button
|
||||
type="primary"
|
||||
class="next-step-button"
|
||||
@click="handleNextStep"
|
||||
>
|
||||
{{ t('auth.forget.password.next_step') }}
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
|
||||
<div class="login-link">
|
||||
{{ t('auth.forget.password.back_to_login') }}
|
||||
<span class="login-button" @click="switchToLogin">
|
||||
{{ t('auth.forget.password.login') }}
|
||||
</span>
|
||||
</div>
|
||||
</el-form>
|
||||
</div>
|
||||
|
||||
<!-- 第二步:设置新密码 -->
|
||||
<div v-else>
|
||||
<div class="forget-password-title">{{ t('auth.forget.password.reset_title') }}</div>
|
||||
|
||||
<!-- 验证码发送提示 -->
|
||||
<div class="verification-tip" v-if="verificationSent">
|
||||
{{ t('auth.forget.password.verification_sent', { email: step1Data.email }) }}<br>
|
||||
{{ t('auth.forget.password.verification_instruction') }}
|
||||
</div>
|
||||
|
||||
<el-form
|
||||
:model="step2Data"
|
||||
ref="step2FormRef"
|
||||
:rules="step2Rules"
|
||||
:validate-on-rule-change="false"
|
||||
class="forget-password-form"
|
||||
>
|
||||
<el-form-item prop="password" class="form-item">
|
||||
<div class="input-container">
|
||||
<el-input
|
||||
v-model="step2Data.password"
|
||||
:placeholder="t('auth.forget.password.password_placeholder')"
|
||||
type="password"
|
||||
show-password
|
||||
clearable
|
||||
class="forget-password-input"
|
||||
>
|
||||
<template #prefix>
|
||||
<Icon
|
||||
icon="ant-design:lock-outlined"
|
||||
:color="isDarkMode ? '#ffffff' : '#000000'"
|
||||
class="input-prefix-icon"
|
||||
/>
|
||||
</template>
|
||||
</el-input>
|
||||
</div>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item prop="confirm_password" class="form-item">
|
||||
<div class="input-container">
|
||||
<el-input
|
||||
v-model="step2Data.confirm_password"
|
||||
:placeholder="t('auth.forget.password.confirm_password_placeholder')"
|
||||
type="password"
|
||||
show-password
|
||||
clearable
|
||||
class="forget-password-input"
|
||||
>
|
||||
<template #prefix>
|
||||
<Icon
|
||||
icon="ant-design:lock-outlined"
|
||||
:color="isDarkMode ? '#ffffff' : '#000000'"
|
||||
class="input-prefix-icon"
|
||||
/>
|
||||
</template>
|
||||
</el-input>
|
||||
</div>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item prop="code" class="form-item">
|
||||
<div class="email-code-container">
|
||||
<div class="input-container">
|
||||
<el-input
|
||||
v-model="step2Data.code"
|
||||
:placeholder="t('auth.forget.password.verification_code_placeholder')"
|
||||
class="forget-password-input"
|
||||
>
|
||||
<template #prefix>
|
||||
<Icon
|
||||
icon="ant-design:key-outlined"
|
||||
:color="isDarkMode ? '#ffffff' : '#000000'"
|
||||
class="input-prefix-icon"
|
||||
/>
|
||||
</template>
|
||||
</el-input>
|
||||
</div>
|
||||
<el-button
|
||||
type="primary"
|
||||
class="send-email-code-button"
|
||||
:disabled="emailCodeCountdown > 0"
|
||||
@click="handleSendEmailCode"
|
||||
>
|
||||
{{ emailCodeCountdown > 0 ? `${emailCodeCountdown}s` : t('auth.forget.password.resend_code') }}
|
||||
</el-button>
|
||||
</div>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item class="form-item">
|
||||
<el-button
|
||||
type="primary"
|
||||
class="reset-password-button"
|
||||
@click="handleResetPassword"
|
||||
>
|
||||
{{ t('auth.forget.password.reset_password') }}
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
|
||||
<div class="prev-step-container">
|
||||
<span class="prev-step" @click="handlePrevStep">
|
||||
<Icon icon="ant-design:arrow-left-outlined" />
|
||||
{{ t('auth.forget.password.prev_step') }}
|
||||
</span>
|
||||
</div>
|
||||
</el-form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, reactive, computed, onMounted } from 'vue'
|
||||
import { forgetPassword, verifyEmail, sendEmailCode } from '@/api/auth'
|
||||
import type { FormInstance } from 'element-plus'
|
||||
import { Icon } from '~/components/icon'
|
||||
import { t } from '~/composables/lang'
|
||||
import { useCaptcha } from '~/composables/captcha'
|
||||
import validate from '~/utils/validate'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
// 暗黑模式状态
|
||||
const isDarkMode = computed(() => {
|
||||
const root = document.querySelector('html')
|
||||
return root?.classList.contains('dark') || false
|
||||
})
|
||||
|
||||
// 当前步骤
|
||||
const currentStep = ref(1)
|
||||
const isLoading = ref(false)
|
||||
const emailCodeCountdown = ref(0)
|
||||
const verificationSent = ref(false)
|
||||
let emailCodeTimer: any = null
|
||||
|
||||
// 第一步数据:邮箱 + 图片验证码
|
||||
const step1Data = reactive({
|
||||
email: '',
|
||||
captcha_code: '',
|
||||
captcha_key: ''
|
||||
})
|
||||
|
||||
// 第二步数据:新密码 + 确认密码 + 验证码
|
||||
const step2Data = reactive({
|
||||
password: '',
|
||||
confirm_password: '',
|
||||
code: ''
|
||||
})
|
||||
|
||||
// 存储邮箱,用于第二步
|
||||
const savedEmail = ref('')
|
||||
|
||||
// 表单引用
|
||||
const step1FormRef = ref<FormInstance>()
|
||||
const step2FormRef = ref<FormInstance>()
|
||||
|
||||
// 使用验证码组合式函数
|
||||
const captcha = useCaptcha(step1Data)
|
||||
|
||||
// 第一步验证规则
|
||||
const step1Rules = computed(() => ({
|
||||
email: [
|
||||
{
|
||||
type: 'string',
|
||||
required: true,
|
||||
message: t('auth.forget.password.email_required'),
|
||||
trigger: ['blur', 'change']
|
||||
},
|
||||
{
|
||||
validator: (_rule: any, value: string) => {
|
||||
return validate.email(value)
|
||||
},
|
||||
message: t('auth.forget.password.email_invalid'),
|
||||
trigger: ['blur']
|
||||
}
|
||||
],
|
||||
captcha_code: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
message: t('auth.forget.password.captcha_required'),
|
||||
trigger: ['blur', 'change']
|
||||
}
|
||||
}) as any)
|
||||
|
||||
// 第二步验证规则
|
||||
const step2Rules = computed(() => ({
|
||||
password: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
message: t('auth.forget.password.password_required'),
|
||||
trigger: ['blur', 'change']
|
||||
},
|
||||
confirm_password: [
|
||||
{
|
||||
type: 'string',
|
||||
required: true,
|
||||
message: t('auth.forget.password.confirm_password_required'),
|
||||
trigger: ['blur', 'change']
|
||||
},
|
||||
{
|
||||
validator: (_rule: any, value: string) => {
|
||||
return value === step2Data.password
|
||||
},
|
||||
message: t('auth.forget.password.password_mismatch'),
|
||||
trigger: ['blur', 'change']
|
||||
}
|
||||
],
|
||||
code: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
message: t('auth.forget.password.verification_code_required'),
|
||||
trigger: ['blur', 'change']
|
||||
}
|
||||
}) as any)
|
||||
|
||||
// 发送邮箱验证码
|
||||
const handleSendEmailCode = async () => {
|
||||
if (!step1Data.email || !validate.email(step1Data.email)) {
|
||||
ElMessage.error(t('auth.forget.password.email_invalid'))
|
||||
return
|
||||
}
|
||||
|
||||
if (emailCodeCountdown.value > 0) return
|
||||
|
||||
try {
|
||||
await sendEmailCode({
|
||||
email: step1Data.email,
|
||||
captcha_key: step1Data.captcha_key,
|
||||
captcha_code: step1Data.captcha_code
|
||||
})
|
||||
|
||||
ElMessage.success(t('auth.forget.password.code_sent'))
|
||||
emailCodeCountdown.value = 60
|
||||
|
||||
emailCodeTimer = setInterval(() => {
|
||||
emailCodeCountdown.value--
|
||||
if (emailCodeCountdown.value <= 0) {
|
||||
clearInterval(emailCodeTimer)
|
||||
}
|
||||
}, 1000)
|
||||
} catch (error) {
|
||||
captcha.refresh()
|
||||
}
|
||||
}
|
||||
|
||||
// 组件挂载时加载验证码
|
||||
onMounted(() => {
|
||||
captcha.refresh()
|
||||
})
|
||||
|
||||
// 下一步:验证邮箱
|
||||
const handleNextStep = async () => {
|
||||
await step1FormRef.value?.validate(async (valid) => {
|
||||
if (!valid) return
|
||||
|
||||
if (isLoading.value) return
|
||||
isLoading.value = true
|
||||
|
||||
try {
|
||||
// 发送验证码
|
||||
await sendEmailCode({
|
||||
email: step1Data.email,
|
||||
captcha_key: step1Data.captcha_key,
|
||||
captcha_code: step1Data.captcha_code
|
||||
})
|
||||
|
||||
savedEmail.value = step1Data.email
|
||||
verificationSent.value = true
|
||||
currentStep.value = 2
|
||||
|
||||
// 开始倒计时
|
||||
emailCodeCountdown.value = 60
|
||||
emailCodeTimer = setInterval(() => {
|
||||
emailCodeCountdown.value--
|
||||
if (emailCodeCountdown.value <= 0) {
|
||||
clearInterval(emailCodeTimer)
|
||||
}
|
||||
}, 1000)
|
||||
|
||||
ElMessage.success(t('auth.forget.password.code_sent_check_email'))
|
||||
} catch (error) {
|
||||
captcha.refresh()
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 上一步
|
||||
const handlePrevStep = () => {
|
||||
currentStep.value = 1
|
||||
verificationSent.value = false
|
||||
if (emailCodeTimer) {
|
||||
clearInterval(emailCodeTimer)
|
||||
emailCodeCountdown.value = 0
|
||||
}
|
||||
}
|
||||
|
||||
// 重置密码
|
||||
const handleResetPassword = async () => {
|
||||
await step2FormRef.value?.validate(async (valid) => {
|
||||
if (!valid) return
|
||||
|
||||
if (isLoading.value) return
|
||||
isLoading.value = true
|
||||
|
||||
try {
|
||||
await forgetPassword({
|
||||
email: savedEmail.value,
|
||||
code: step2Data.code,
|
||||
new_password: step2Data.password,
|
||||
confirm_password: step2Data.confirm_password
|
||||
})
|
||||
ElMessage.success(t('auth.forget.password.reset_success'))
|
||||
emit('typeChange', 'login')
|
||||
} catch (error) {
|
||||
captcha.refresh()
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 切换到登录
|
||||
const emit = defineEmits(['typeChange'])
|
||||
const switchToLogin = () => {
|
||||
emit('typeChange', 'login')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.forget-password-container {
|
||||
width: 100%;
|
||||
|
||||
&.dark-mode {
|
||||
background: var(--el-bg-color);
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.forget-password-form-container {
|
||||
background: var(--el-bg-color);
|
||||
width: 100%;
|
||||
padding: 60px 30px;
|
||||
border-radius: var(--rounded-big);
|
||||
}
|
||||
|
||||
.forget-password-title {
|
||||
font-size: 24px;
|
||||
color: var(--el-text-color-primary);
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
margin-bottom: 30px;
|
||||
font-family: 'OppoSans-R';
|
||||
}
|
||||
|
||||
.forget-password-tip {
|
||||
font-size: 14px;
|
||||
color: var(--el-text-color-secondary);
|
||||
text-align: center;
|
||||
margin-bottom: 40px;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.verification-tip {
|
||||
background-color: #E8F5E8;
|
||||
color: #2E7D32;
|
||||
padding: 15px;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 30px;
|
||||
text-align: center;
|
||||
font-size: 14px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.forget-password-form {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.form-item {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.captcha-image {
|
||||
height: 30px;
|
||||
cursor: pointer;
|
||||
|
||||
img {
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.iconify {
|
||||
font-size: 24px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
}
|
||||
|
||||
.input-container {
|
||||
flex: 1;
|
||||
height: 50px;
|
||||
border: 1px solid var(--el-border-color);
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background: var(--el-bg-color);
|
||||
}
|
||||
|
||||
.forget-password-input {
|
||||
width: 100%;
|
||||
border: none;
|
||||
|
||||
:deep(.el-input__wrapper) {
|
||||
box-shadow: none;
|
||||
background: transparent;
|
||||
|
||||
.el-input__inner {
|
||||
color: var(--el-text-color-primary);
|
||||
|
||||
&::placeholder {
|
||||
color: var(--el-text-color-placeholder);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.el-input__suffix) {
|
||||
padding: 0;
|
||||
line-height: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.input-prefix-icon {
|
||||
margin-right: 14px;
|
||||
}
|
||||
|
||||
.email-code-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.send-email-code-button {
|
||||
flex-shrink: 0;
|
||||
height: 50px;
|
||||
white-space: nowrap;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.login-link {
|
||||
text-align: center;
|
||||
margin-top: 20px;
|
||||
font-size: 14px;
|
||||
color: var(--el-text-color-secondary);
|
||||
|
||||
.login-button {
|
||||
color: var(--el-color-primary);
|
||||
cursor: pointer;
|
||||
margin-left: 5px;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.next-step-button,
|
||||
.reset-password-button {
|
||||
width: 100%;
|
||||
height: 50px;
|
||||
border-radius: 8px;
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.prev-step-container {
|
||||
text-align: center;
|
||||
margin-top: 20px;
|
||||
|
||||
.prev-step {
|
||||
color: var(--el-color-primary);
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,83 @@
|
||||
<template>
|
||||
<div class="loginPopup">
|
||||
<el-dialog
|
||||
v-model="dialogVisible"
|
||||
align-center
|
||||
width="450"
|
||||
:before-close="beforeClose"
|
||||
custom-class="login !rounded-[var(--rounded-big)]"
|
||||
:show-close="false"
|
||||
append-to-body
|
||||
>
|
||||
<login v-if="type === 'login' && dialogVisible" @typeChange="typeChange" />
|
||||
<register v-if="type === 'register' && dialogVisible" @typeChange="typeChange" />
|
||||
<forget-password v-if="type === 'forget-password' && dialogVisible" @typeChange="typeChange" />
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import { ref, computed, watch } from "vue";
|
||||
import login from "./login.vue";
|
||||
import register from "./register.vue";
|
||||
import forgetPassword from "./forget-password.vue";
|
||||
import { useMemberStore } from '~/stores/member';
|
||||
|
||||
const type = ref("login");
|
||||
|
||||
const memberStore = useMemberStore();
|
||||
//弹框状态
|
||||
const dialogVisible = computed(() => {
|
||||
return memberStore.loginPopup;
|
||||
});
|
||||
//弹框关闭
|
||||
const beforeClose = (next: any) => {
|
||||
memberStore.logClose();
|
||||
type.value = "login";
|
||||
next();
|
||||
};
|
||||
const handleClose = () => {
|
||||
memberStore.logClose();
|
||||
type.value = "login";
|
||||
};
|
||||
//判断当前是登录还是注册
|
||||
const typeChange = (val: any) => {
|
||||
type.value = val;
|
||||
};
|
||||
</script>
|
||||
<style>
|
||||
.login .el-dialog__header {
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
.login .el-dialog__body {
|
||||
padding: 0 !important;
|
||||
}
|
||||
.login .el-dialog__headerbtn {
|
||||
z-index: 99;
|
||||
}
|
||||
</style>
|
||||
<style lang="scss" scoped>
|
||||
:deep(.el-form-item) {
|
||||
.el-input__wrapper {
|
||||
box-shadow: unset !important;
|
||||
border-radius: 0;
|
||||
&.is-focus {
|
||||
border: none;
|
||||
}
|
||||
}
|
||||
|
||||
&.is-error {
|
||||
.el-input__wrapper {
|
||||
border: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.el-form-item__error) {
|
||||
padding-top: 5px;
|
||||
}
|
||||
|
||||
.text-color {
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,675 @@
|
||||
<template>
|
||||
<div class="login-container" :class="{'dark-mode': isDarkMode}">
|
||||
<!-- 登录方式切换图标 -->
|
||||
<div class="login-mode-toggle">
|
||||
<Icon
|
||||
v-if="isAccountLoginActive"
|
||||
@click="toggleLoginMode"
|
||||
icon="ant-design:qrcode-outlined"
|
||||
:color="isDarkMode ? '#ffffff' : '#000000'"
|
||||
size="50px"
|
||||
class="login-toggle-icon"
|
||||
/>
|
||||
<Icon
|
||||
v-else
|
||||
@click="toggleLoginMode"
|
||||
icon="ant-design:desktop-outlined"
|
||||
:color="isDarkMode ? '#ffffff' : '#000000'"
|
||||
size="50px"
|
||||
class="login-toggle-icon"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 账号密码登录表单 -->
|
||||
<div v-if="isAccountLoginActive" class="login-form-container">
|
||||
<div class="login-type-tabs">
|
||||
<div
|
||||
v-for="(item, index) in availableLoginTypes"
|
||||
:key="item.type"
|
||||
class="login-type-tab"
|
||||
:class="{
|
||||
'login-type-tab--active': currentLoginType === item.type,
|
||||
'login-type-tab--margin': index + 1 !== availableLoginTypes.length,
|
||||
}"
|
||||
@click="currentLoginType = item.type"
|
||||
>
|
||||
{{ item.title }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-form
|
||||
:model="formData"
|
||||
ref="formRef"
|
||||
:rules="formRules"
|
||||
:validate-on-rule-change="false"
|
||||
class="login-form"
|
||||
>
|
||||
<!-- 用户名密码登录 -->
|
||||
<div v-show="currentLoginType === 'username'" class="login-form-section">
|
||||
<el-form-item prop="username" class="form-item">
|
||||
<div class="input-container">
|
||||
<el-input
|
||||
v-model="formData.username"
|
||||
:placeholder="t('auth.login.username_placeholder')"
|
||||
clearable
|
||||
:inline-message="true"
|
||||
:readonly="isUsernameInputReadonly"
|
||||
@click="isUsernameInputReadonly = false"
|
||||
@blur="isUsernameInputReadonly = true"
|
||||
class="login-input"
|
||||
>
|
||||
<template #prefix>
|
||||
<Icon
|
||||
icon="ant-design:user-outlined"
|
||||
:color="isDarkMode ? '#ffffff' : '#000000'"
|
||||
class="input-prefix-icon"
|
||||
/>
|
||||
</template>
|
||||
</el-input>
|
||||
</div>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item prop="password" class="form-item">
|
||||
<div class="input-container">
|
||||
<el-input
|
||||
v-model="formData.password"
|
||||
:placeholder="t('auth.login.password_placeholder')"
|
||||
type="password"
|
||||
clearable
|
||||
show-password
|
||||
class="login-input"
|
||||
>
|
||||
<template #prefix>
|
||||
<Icon
|
||||
icon="ant-design:lock-outlined"
|
||||
:color="isDarkMode ? '#ffffff' : '#000000'"
|
||||
class="input-prefix-icon"
|
||||
/>
|
||||
</template>
|
||||
</el-input>
|
||||
</div>
|
||||
</el-form-item>
|
||||
</div>
|
||||
|
||||
<!-- 手机号登录 -->
|
||||
<div v-show="currentLoginType === 'mobile'" class="login-form-section">
|
||||
<el-form-item prop="mobile" class="form-item">
|
||||
<div class="input-container">
|
||||
<el-input
|
||||
v-model="formData.mobile"
|
||||
:placeholder="t('auth.login.mobile_placeholder')"
|
||||
clearable
|
||||
class="login-input"
|
||||
>
|
||||
<template #prefix>
|
||||
<Icon
|
||||
icon="ant-design:phone-outlined"
|
||||
:color="isDarkMode ? '#ffffff' : '#000000'"
|
||||
class="input-prefix-icon"
|
||||
/>
|
||||
</template>
|
||||
</el-input>
|
||||
</div>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item prop="mobile_code" class="form-item">
|
||||
<div class="input-container">
|
||||
<el-input
|
||||
v-model="formData.mobile_code"
|
||||
:placeholder="t('auth.login.code_placeholder')"
|
||||
class="login-input"
|
||||
>
|
||||
<template #prefix>
|
||||
<Icon
|
||||
icon="ant-design:key-outlined"
|
||||
:color="isDarkMode ? '#ffffff' : '#000000'"
|
||||
class="input-prefix-icon"
|
||||
/>
|
||||
</template>
|
||||
<template #suffix>
|
||||
<sms-code
|
||||
:mobile="formData.mobile"
|
||||
type="login"
|
||||
v-model="formData.mobile_key"
|
||||
@click="handleSendSmsCode"
|
||||
ref="smsCodeRef"
|
||||
></sms-code>
|
||||
</template>
|
||||
</el-input>
|
||||
</div>
|
||||
</el-form-item>
|
||||
</div>
|
||||
|
||||
<!-- 注册链接和忘记密码 -->
|
||||
<div class="form-actions">
|
||||
<el-button type="primary" link @click="switchToRegister" class="register-link">
|
||||
{{ t("auth.login.no_account") }},{{ t("auth.login.to_register") }}
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="currentLoginType === 'username'"
|
||||
type="primary"
|
||||
link
|
||||
@click="switchToForgetPassword"
|
||||
class="forget-password-link"
|
||||
>
|
||||
{{ t("auth.login.resetpwd") }}
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<!-- 登录按钮 -->
|
||||
<div class="login-button-container">
|
||||
<el-button
|
||||
type="primary"
|
||||
class="login-button"
|
||||
size="large"
|
||||
@click="handleLoginSubmit"
|
||||
:loading="isLoading"
|
||||
>
|
||||
{{ isLoading ? t("auth.login.logining") : t("auth.login.login") }}
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<!-- 协议同意 -->
|
||||
<div v-if="configStore.login.agreement_show" class="agreement-container">
|
||||
<span
|
||||
class="iconfont agreement-checkbox"
|
||||
:class="isAgreeProtocol ? 'icon-xuanze1' : 'icon-checkbox_nol'"
|
||||
@click="isAgreeProtocol = !isAgreeProtocol"
|
||||
></span>
|
||||
{{ t("agree_tips") }}
|
||||
<NuxtLink :to="protocolUrls.serviceUrl" target="_blank">
|
||||
<span class="agreement-link">{{ t("auth.register.user_agreement") }}</span>
|
||||
</NuxtLink>
|
||||
{{ t("and") }}
|
||||
<NuxtLink :to="protocolUrls.privacyUrl" target="_blank">
|
||||
<span class="agreement-link">{{ t("auth.register.privacy_agreement") }}</span>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</el-form>
|
||||
</div>
|
||||
|
||||
<!-- 微信扫码登录 -->
|
||||
<div v-else class="wechat-login-container">
|
||||
<div class="wechat-login-title">{{ t("auth.login.wechat_scan_login") }}</div>
|
||||
<div class="qrcode-container">
|
||||
<div class="qrcode-wrapper">
|
||||
<el-image v-if="wechatQrCode.url" :src="wechatQrCode.url" class="qrcode-image" />
|
||||
<div v-else class="qrcode-placeholder"></div>
|
||||
<div v-if="wechatQrCode.isExpired" class="qrcode-overlay">
|
||||
<span class="qrcode-error-text">{{ wechatQrCode.expiredMessage }}</span>
|
||||
<span @click="handleGenerateWechatQrCode" class="qrcode-refresh-link">{{ t("auth.login.click_refresh") }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="wechat-tip">
|
||||
<span class="iconfont icon-weixin1 wechat-icon"></span>
|
||||
<span class="wechat-text">{{ t("auth.login.wechat_scan_tip") }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, reactive, computed, onUnmounted } from "vue";
|
||||
import { authenticateUser, authenticateWithMobile, authenticateWithWechat,generateWechatQrCode, checkWechatScanStatus } from "@/api/auth";
|
||||
import useConfigStore from "@/stores/config";
|
||||
import { useMemberStore } from "@/stores/member";
|
||||
import QRCode from "qrcode";
|
||||
import type { FormInstance } from "element-plus";
|
||||
import { Icon } from '~/components/icon'
|
||||
import {t}from "~/composables/lang";
|
||||
import { ElMessage } from "element-plus";
|
||||
import validate from "~/utils/validate";
|
||||
|
||||
// Store 实例
|
||||
const memberStore = useMemberStore();
|
||||
const configStore = useConfigStore();
|
||||
|
||||
// 暗黑模式状态
|
||||
const isDarkMode = computed(() => {
|
||||
const root = document.querySelector('html')
|
||||
return root?.classList.contains('dark') || false
|
||||
});
|
||||
|
||||
// 协议链接配置
|
||||
const protocolUrls = computed(() => {
|
||||
const basePath = location.pathname.includes("web") ? "/web/auth/agreement" : "/auth/agreement";
|
||||
return {
|
||||
serviceUrl: `${basePath}?key=service`,
|
||||
privacyUrl: `${basePath}?key=privacy`,
|
||||
};
|
||||
})as any;
|
||||
|
||||
// 登录模式状态
|
||||
const isAccountLoginActive = ref(true);
|
||||
const currentLoginType = ref("");
|
||||
let qrCodeTimer: any = null;
|
||||
|
||||
// 微信扫码登录状态
|
||||
const wechatQrCode = ref({
|
||||
url: "",
|
||||
key: "",
|
||||
isExpired: false,
|
||||
expiredMessage: t("qrcode_generate_failed"),
|
||||
});
|
||||
|
||||
// 表单数据
|
||||
const isLoading = ref(false);
|
||||
const isAgreeProtocol = ref(false);
|
||||
const isUsernameInputReadonly = ref(true);
|
||||
|
||||
const formData = reactive({
|
||||
username: "",
|
||||
password: "",
|
||||
mobile: "",
|
||||
mobile_code: "",
|
||||
mobile_key: "",
|
||||
});
|
||||
|
||||
// 表单引用
|
||||
const formRef = ref<FormInstance>();
|
||||
const smsCodeRef = ref<AnyObject | null>(null);
|
||||
|
||||
// 计算属性
|
||||
const availableLoginTypes = computed(() => {
|
||||
const types = [];
|
||||
if (configStore.login.is_username) {
|
||||
types.push({ type: "username", title: t("auth.login.username_login") });
|
||||
}
|
||||
if (configStore.login.is_mobile) {
|
||||
types.push({ type: "mobile", title: t("auth.login.mobile_login") });
|
||||
}
|
||||
currentLoginType.value = types[0] ? types[0].type : "";
|
||||
return types;
|
||||
});
|
||||
|
||||
const formRules = computed(() => ({
|
||||
username: {
|
||||
required: currentLoginType.value === "username",
|
||||
message: t("auth.login.username_placeholder"),
|
||||
trigger: ["blur", "change"],
|
||||
},
|
||||
password: {
|
||||
required: currentLoginType.value === "username",
|
||||
message: t("auth.login.password_placeholder"),
|
||||
trigger: ["blur", "change"],
|
||||
},
|
||||
mobile: [
|
||||
{
|
||||
required: currentLoginType.value === "mobile",
|
||||
message: t("mobile_placeholder"),
|
||||
trigger: ["blur", "change"],
|
||||
},
|
||||
{
|
||||
validator(rule: any, value: string, callback: any) {
|
||||
if (currentLoginType.value !== "mobile") return true;
|
||||
return validate.mobile(value);
|
||||
},
|
||||
message: t("auth.login.mobile_error"),
|
||||
trigger: ["blur"],
|
||||
},
|
||||
],
|
||||
mobile_code: {
|
||||
required: currentLoginType.value === "mobile",
|
||||
message: t("auth.login.sms_code_placeholder"),
|
||||
trigger: ["change"],
|
||||
},
|
||||
}));
|
||||
|
||||
// 生命周期
|
||||
onUnmounted(() => {
|
||||
clearTimeout(qrCodeTimer);
|
||||
});
|
||||
|
||||
// 初始化配置
|
||||
configStore.getWebGroupConfigs();
|
||||
|
||||
// 登录模式切换
|
||||
const toggleLoginMode = () => {
|
||||
if (!isAccountLoginActive.value) {
|
||||
isAccountLoginActive.value = !isAccountLoginActive.value;
|
||||
clearTimeout(qrCodeTimer);
|
||||
} else {
|
||||
// 检查是否开启微信登录
|
||||
if (!configStore.login?.is_wechat) {
|
||||
ElMessage.error(t('auth.login.wechat_login_not_available'));
|
||||
return;
|
||||
}
|
||||
isAccountLoginActive.value = !isAccountLoginActive.value;
|
||||
handleGenerateWechatQrCode();
|
||||
}
|
||||
};
|
||||
|
||||
// 微信扫码登录相关方法
|
||||
const handleGenerateWechatQrCode = async () => {
|
||||
try {
|
||||
const data = await generateWechatQrCode() as any;
|
||||
|
||||
|
||||
wechatQrCode.value.key = data.scene_id;
|
||||
|
||||
if (data.qr_code_url) {
|
||||
const qrCodeUrl = await QRCode.toDataURL(data.qr_code_url, {
|
||||
errorCorrectionLevel: "L",
|
||||
margin: 0,
|
||||
width: 100,
|
||||
});
|
||||
|
||||
wechatQrCode.value.url = qrCodeUrl;
|
||||
wechatQrCode.value.isExpired = false;
|
||||
|
||||
setTimeout(() => {
|
||||
checkScan(wechatQrCode.value.key);
|
||||
}, 1000);
|
||||
}
|
||||
} catch (error) {
|
||||
wechatQrCode.value.isExpired = true;
|
||||
wechatQrCode.value.expiredMessage = t("qrcode_generate_failed");
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 检查微信扫码登录状态
|
||||
* @param key - 扫码登录密钥
|
||||
*/
|
||||
const checkScan = (scene_id: string) => {
|
||||
checkWechatScanStatus({ scene_id })
|
||||
.then((data: any) => {
|
||||
switch (data.status) {
|
||||
case "wait":
|
||||
qrCodeTimer = setTimeout(() => {
|
||||
checkScan(wechatQrCode?.value.key);
|
||||
}, 1000);
|
||||
break;
|
||||
case "success":
|
||||
handleWechatLoginSuccess(data.login_data);
|
||||
break;
|
||||
case "fail":
|
||||
wechatQrCode.value.expiredMessage = data.fail_reason;
|
||||
wechatQrCode.value.isExpired = true;
|
||||
break;
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
wechatQrCode.value.isExpired = true;
|
||||
wechatQrCode.value.expiredMessage = error.msg || t("scan_check_failed");
|
||||
});
|
||||
};
|
||||
|
||||
const handleWechatLoginSuccess = (loginData: any) => {
|
||||
if (!loginData.access_token) {
|
||||
useCookie("openId").value = loginData.openid;
|
||||
navigateTo("/auth/bind");
|
||||
memberStore.logClose();
|
||||
} else {
|
||||
memberStore.setToken(loginData.access_token, loginData.refresh_token || '');
|
||||
memberStore.logClose();
|
||||
}
|
||||
};
|
||||
|
||||
// 表单处理方法
|
||||
const handleLoginSubmit = async () => {
|
||||
const isValid = await formRef.value?.validate();
|
||||
if (!isValid) return;
|
||||
|
||||
if (configStore.login.agreement_show && !isAgreeProtocol.value) {
|
||||
ElMessage.error(t("auth.login.is_agree_tips"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (isLoading.value) return;
|
||||
isLoading.value = true;
|
||||
|
||||
try {
|
||||
const loginMethod = currentLoginType.value === "username" ? authenticateUser : authenticateWithMobile;
|
||||
const data = await loginMethod(formData) as any;
|
||||
|
||||
await memberStore.setToken(data?.access_token || '', data?.refresh_token || '');
|
||||
memberStore.logClose();
|
||||
} catch (error) {
|
||||
isLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleSendSmsCode = async () => {
|
||||
const isValid = await formRef.value?.validateField("mobile");
|
||||
if (isValid) {
|
||||
smsCodeRef.value?.send();
|
||||
}
|
||||
};
|
||||
|
||||
// 组件通信
|
||||
const emit = defineEmits(["typeChange"]);
|
||||
const switchToRegister = () => {
|
||||
emit("typeChange", "register");
|
||||
};
|
||||
const switchToForgetPassword = () => {
|
||||
emit("typeChange", "forget-password");
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.login-container {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
|
||||
&.dark-mode {
|
||||
background: var(--el-bg-color);
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.login-mode-toggle {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
}
|
||||
|
||||
.login-toggle-icon {
|
||||
font-size: 50px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.login-form-container {
|
||||
background: var(--el-bg-color);
|
||||
width: 100%;
|
||||
padding: 60px 30px;
|
||||
border-radius: var(--rounded-big);
|
||||
}
|
||||
|
||||
.login-type-tabs {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: center;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.login-type-tab {
|
||||
font-size: 18px;
|
||||
cursor: pointer;
|
||||
color: var(--el-text-color-secondary);
|
||||
line-height: 24px;
|
||||
font-family: "OppoSans-R";
|
||||
|
||||
&--active {
|
||||
color: var(--el-text-color-primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
&--margin {
|
||||
margin-right: 70px;
|
||||
}
|
||||
}
|
||||
|
||||
.login-form {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.form-item {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.input-container {
|
||||
flex: 1;
|
||||
height: 50px;
|
||||
border: 1px solid var(--el-border-color);
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background: var(--el-bg-color);
|
||||
}
|
||||
|
||||
.login-input {
|
||||
width: 100%;
|
||||
border: none;
|
||||
|
||||
:deep(.el-input__wrapper) {
|
||||
box-shadow: none;
|
||||
background: transparent;
|
||||
|
||||
.el-input__inner {
|
||||
color: var(--el-text-color-primary);
|
||||
|
||||
&::placeholder {
|
||||
color: var(--el-text-color-placeholder);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.input-prefix-icon {
|
||||
margin-right: 14px;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.register-link,
|
||||
.forget-password-link {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.login-button-container {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.login-button {
|
||||
width: 100%;
|
||||
height: 50px;
|
||||
border-radius: 8px;
|
||||
font-family: "OppoSans-M";
|
||||
}
|
||||
|
||||
.agreement-container {
|
||||
font-size: 12px;
|
||||
line-height: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
margin-top: 20px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
.agreement-checkbox {
|
||||
color: var(--el-color-primary);
|
||||
margin-right: 5px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.agreement-link {
|
||||
color: var(--el-color-primary);
|
||||
margin: 0 4px;
|
||||
|
||||
&:hover {
|
||||
color: var(--el-color-primary-light-3);
|
||||
}
|
||||
}
|
||||
|
||||
.wechat-login-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 60px 30px;
|
||||
background: var(--el-bg-color);
|
||||
}
|
||||
|
||||
.wechat-login-title {
|
||||
font-size: 18px;
|
||||
color: var(--el-text-color-primary);
|
||||
font-weight: 600;
|
||||
line-height: 24px;
|
||||
font-family: "OppoSans-R";
|
||||
}
|
||||
|
||||
.qrcode-container {
|
||||
margin-top: 30px;
|
||||
}
|
||||
|
||||
.qrcode-wrapper {
|
||||
padding: 20px;
|
||||
border: 1px solid var(--el-border-color);
|
||||
border-radius: var(--rounded-small);
|
||||
position: relative;
|
||||
background: var(--el-bg-color);
|
||||
}
|
||||
|
||||
.qrcode-image {
|
||||
width: 200px;
|
||||
height: 200px;
|
||||
}
|
||||
|
||||
.qrcode-placeholder {
|
||||
width: 202px;
|
||||
height: 202px;
|
||||
background: var(--el-bg-color-page);
|
||||
}
|
||||
|
||||
.qrcode-overlay {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: var(--el-bg-color-overlay);
|
||||
}
|
||||
|
||||
.qrcode-error-text {
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
.qrcode-refresh-link {
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
color: var(--el-color-primary);
|
||||
margin-top: 8px;
|
||||
|
||||
&:hover {
|
||||
color: var(--el-color-primary-light-3);
|
||||
}
|
||||
}
|
||||
|
||||
.wechat-tip {
|
||||
margin-top: 22px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.wechat-icon {
|
||||
color: #00c22c;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.wechat-text {
|
||||
font-size: 14px;
|
||||
color: var(--el-text-color-secondary);
|
||||
margin-left: 4px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,563 @@
|
||||
<template>
|
||||
<div class="register-container" :class="{'dark-mode': isDarkMode}">
|
||||
<div class="register-form-container">
|
||||
<div class="register-type-tabs">
|
||||
<div
|
||||
v-for="(item, index) in registerTypes"
|
||||
:key="item.type"
|
||||
class="register-type-tab"
|
||||
:class="{
|
||||
'register-type-tab--active': currentType === item.type,
|
||||
'register-type-tab--margin': index + 1 !== registerTypes.length
|
||||
}"
|
||||
@click="currentType = item.type"
|
||||
>
|
||||
{{ item.title }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-form
|
||||
:model="formData"
|
||||
ref="formRef"
|
||||
:rules="formRules"
|
||||
:validate-on-rule-change="false"
|
||||
class="register-form"
|
||||
>
|
||||
<!-- 用户名注册 -->
|
||||
<div v-show="currentType === 'username'" class="register-form-section">
|
||||
<el-form-item prop="username" class="form-item">
|
||||
<div class="input-container">
|
||||
<el-input
|
||||
v-model="formData.username"
|
||||
:placeholder="t('auth.register.username_placeholder')"
|
||||
clearable
|
||||
:inline-message="true"
|
||||
:readonly="realNameInput"
|
||||
@click="realNameInput = false"
|
||||
@blur="realNameInput = true"
|
||||
class="register-input"
|
||||
>
|
||||
<template #prefix>
|
||||
<Icon
|
||||
icon="ant-design:user-outlined"
|
||||
:color="isDarkMode ? '#ffffff' : '#000000'"
|
||||
class="input-prefix-icon"
|
||||
/>
|
||||
</template>
|
||||
</el-input>
|
||||
</div>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item prop="email" class="form-item">
|
||||
<div class="input-container">
|
||||
<el-input
|
||||
v-model="formData.email"
|
||||
:placeholder="t('auth.register.email_placeholder')"
|
||||
clearable
|
||||
class="register-input"
|
||||
>
|
||||
<template #prefix>
|
||||
<Icon
|
||||
icon="ant-design:mail-outlined"
|
||||
:color="isDarkMode ? '#ffffff' : '#000000'"
|
||||
class="input-prefix-icon"
|
||||
/>
|
||||
</template>
|
||||
</el-input>
|
||||
</div>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item prop="password" class="form-item">
|
||||
<div class="input-container">
|
||||
<el-input
|
||||
v-model="formData.password"
|
||||
:placeholder="t('auth.register.password_placeholder')"
|
||||
type="password"
|
||||
clearable
|
||||
show-password
|
||||
class="register-input"
|
||||
>
|
||||
<template #prefix>
|
||||
<Icon
|
||||
icon="ant-design:lock-outlined"
|
||||
:color="isDarkMode ? '#ffffff' : '#000000'"
|
||||
class="input-prefix-icon"
|
||||
/>
|
||||
</template>
|
||||
</el-input>
|
||||
</div>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item prop="confirmPassword" class="form-item">
|
||||
<div class="input-container">
|
||||
<el-input
|
||||
v-model="formData.confirm_password"
|
||||
:placeholder="t('auth.register.confirm_password_placeholder')"
|
||||
type="password"
|
||||
clearable
|
||||
show-password
|
||||
class="register-input"
|
||||
>
|
||||
<template #prefix>
|
||||
<Icon
|
||||
icon="ant-design:lock-outlined"
|
||||
:color="isDarkMode ? '#ffffff' : '#000000'"
|
||||
class="input-prefix-icon"
|
||||
/>
|
||||
</template>
|
||||
</el-input>
|
||||
</div>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item prop="captchaCode" class="form-item">
|
||||
<div class="input-container">
|
||||
<el-input
|
||||
v-model="formData.captcha_code"
|
||||
:placeholder="t('auth.register.captcha_placeholder')"
|
||||
class="register-input"
|
||||
>
|
||||
<template #prefix>
|
||||
<Icon
|
||||
icon="ant-design:safety-outlined"
|
||||
:color="isDarkMode ? '#ffffff' : '#000000'"
|
||||
class="input-prefix-icon"
|
||||
/>
|
||||
</template>
|
||||
<template #suffix>
|
||||
<div class="captcha-image-container">
|
||||
<el-image
|
||||
:src="captcha.image.value"
|
||||
class="captcha-image"
|
||||
@click="captcha.refresh()"
|
||||
></el-image>
|
||||
</div>
|
||||
</template>
|
||||
</el-input>
|
||||
</div>
|
||||
</el-form-item>
|
||||
</div>
|
||||
|
||||
<!-- 手机号注册 -->
|
||||
<div v-show="currentType === 'mobile' || configStore.login.is_bind_mobile" class="register-form-section">
|
||||
<el-form-item prop="mobile" class="form-item">
|
||||
<div class="input-container">
|
||||
<el-input
|
||||
v-model="formData.mobile"
|
||||
:placeholder="t('auth.register.mobile_placeholder')"
|
||||
clearable
|
||||
class="register-input"
|
||||
>
|
||||
<template #prefix>
|
||||
<Icon
|
||||
icon="ant-design:phone-outlined"
|
||||
:color="isDarkMode ? '#ffffff' : '#000000'"
|
||||
class="input-prefix-icon"
|
||||
/>
|
||||
</template>
|
||||
</el-input>
|
||||
</div>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item prop="mobileCode" class="form-item">
|
||||
<div class="input-container">
|
||||
<el-input
|
||||
v-model="formData.mobile_code"
|
||||
:placeholder="t('auth.register.code_placeholder')"
|
||||
class="register-input"
|
||||
>
|
||||
<template #prefix>
|
||||
<Icon
|
||||
icon="ant-design:key-outlined"
|
||||
:color="isDarkMode ? '#ffffff' : '#000000'"
|
||||
class="input-prefix-icon"
|
||||
/>
|
||||
</template>
|
||||
<template #suffix>
|
||||
<sms-code
|
||||
:mobile="formData.mobile"
|
||||
type="login"
|
||||
v-model="formData.mobile_key"
|
||||
@click="sendSmsCode"
|
||||
ref="smsCodeRef"
|
||||
></sms-code>
|
||||
</template>
|
||||
</el-input>
|
||||
</div>
|
||||
</el-form-item>
|
||||
</div>
|
||||
|
||||
<!-- 登录链接 -->
|
||||
<div class="form-actions">
|
||||
<el-button type="primary" link @click="switchToLogin" class="login-link">
|
||||
{{ t('auth.register.have_account') }},{{ t('auth.register.to_login') }}
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<!-- 注册按钮 -->
|
||||
<div class="register-button-container">
|
||||
<el-button
|
||||
type="primary"
|
||||
class="register-button"
|
||||
size="large"
|
||||
@click="handleRegister"
|
||||
:loading="isLoading"
|
||||
>
|
||||
{{ isLoading ? t('auth.register.registering') : t('auth.register.register') }}
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<!-- 协议同意 -->
|
||||
<div
|
||||
v-if="configStore.login.agreement_show"
|
||||
class="agreement-container"
|
||||
>
|
||||
<span
|
||||
class="iconfont agreement-checkbox"
|
||||
:class="isAgreeChecked ? 'icon-xuanze1' : 'icon-checkbox_nol'"
|
||||
@click="toggleAgreement"
|
||||
></span>
|
||||
{{ t('auth.register.register_agree_tips') }}
|
||||
<NuxtLink :to="protocolUrls.service" target="_blank">
|
||||
<span class="agreement-link">{{ t('auth.register.user_agreement') }}</span>
|
||||
</NuxtLink>
|
||||
{{ t('auth.register.and') }}
|
||||
<NuxtLink :to="protocolUrls.privacy" target="_blank">
|
||||
<span class="agreement-link">{{ t('auth.register.privacy_agreement') }}</span>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</el-form>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, reactive, computed } from 'vue'
|
||||
import { registerUser, registerWithMobile } from '@/api/auth'
|
||||
import { useConfigStore } from '@/stores/config'
|
||||
import { useMemberStore } from '@/stores/member'
|
||||
import { useCaptcha } from '~/composables/captcha'
|
||||
import type { FormInstance } from 'element-plus'
|
||||
import { Icon } from '~/components/icon'
|
||||
import {t} from '~/composables/lang'
|
||||
import validate from '~/utils/validate'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
|
||||
// 状态管理
|
||||
const memberStore = useMemberStore()
|
||||
const configStore = useConfigStore()
|
||||
|
||||
// 暗黑模式状态
|
||||
const isDarkMode = computed(() => {
|
||||
return configStore.isDarkMode
|
||||
})
|
||||
|
||||
// 初始化配置
|
||||
// 配置已在 app.vue 中统一加载,这里不再重复请求
|
||||
|
||||
// 协议链接配置
|
||||
const protocolUrls = computed(() => {
|
||||
const basePath = location.pathname.includes('web') ? '/web' : ''
|
||||
return {
|
||||
service: `${basePath}/auth/agreement?key=service`,
|
||||
privacy: `${basePath}/auth/agreement?key=privacy`
|
||||
}
|
||||
})
|
||||
|
||||
// 注册类型配置
|
||||
const currentType = ref('')
|
||||
const registerTypes = computed(() => {
|
||||
const types = []
|
||||
if (configStore.login.is_username) {
|
||||
types.push({ type: 'username', title: t('auth.register.username_register') })
|
||||
}
|
||||
if (configStore.login.is_mobile && !configStore.login.is_bind_mobile) {
|
||||
types.push({ type: 'mobile', title: t('auth.register.mobile_register') })
|
||||
}
|
||||
currentType.value = types[0]?.type || ''
|
||||
return types
|
||||
})
|
||||
|
||||
// 表单数据
|
||||
const isLoading = ref(false)
|
||||
const formData = reactive({
|
||||
username: '',
|
||||
email: '',
|
||||
password: '',
|
||||
confirm_password: '',
|
||||
mobile: '',
|
||||
mobile_code: '',
|
||||
mobile_key: '',
|
||||
captcha_key: '',
|
||||
captcha_code: ''
|
||||
})
|
||||
|
||||
// 表单验证规则
|
||||
const formRef = ref<FormInstance>()
|
||||
const formRules = computed(() => ({
|
||||
username: {
|
||||
type: 'string',
|
||||
required: currentType.value === 'username',
|
||||
message: t('auth.register.username_placeholder'),
|
||||
trigger: ['blur', 'change'],
|
||||
},
|
||||
email: [
|
||||
{
|
||||
type: 'string',
|
||||
required: currentType.value === 'username',
|
||||
message: t('auth.register.email_required'),
|
||||
trigger: ['blur', 'change'],
|
||||
},
|
||||
{
|
||||
validator(rule: any, value: string, callback: any) {
|
||||
if (currentType.value !== 'username') return true
|
||||
return validate.email(value)
|
||||
},
|
||||
message: t('auth.register.email_error'),
|
||||
trigger: ['change', 'blur'],
|
||||
}
|
||||
],
|
||||
password: {
|
||||
type: 'string',
|
||||
required: currentType.value === 'username',
|
||||
message: t('auth.register.password_placeholder'),
|
||||
trigger: ['blur', 'change']
|
||||
},
|
||||
confirm_password: [
|
||||
{
|
||||
type: 'string',
|
||||
required: currentType.value === 'username',
|
||||
message: t('auth.register.confirm_password_placeholder'),
|
||||
trigger: ['blur', 'change']
|
||||
},
|
||||
{
|
||||
validator(rule: any, value: string, callback: any) {
|
||||
return value === formData.password
|
||||
},
|
||||
message: t('auth.register.confirm_password_error'),
|
||||
trigger: ['change', 'blur'],
|
||||
}
|
||||
],
|
||||
mobile: [
|
||||
{
|
||||
type: 'string',
|
||||
required: currentType.value === 'mobile' || configStore.login.is_bind_mobile,
|
||||
message: t('auth.register.mobile_placeholder'),
|
||||
trigger: ['blur', 'change'],
|
||||
},
|
||||
{
|
||||
validator(rule: any, value: string, callback: any) {
|
||||
if (currentType.value !== 'mobile' && !configStore.login.is_bind_mobile) return true
|
||||
return validate.mobile(value)
|
||||
},
|
||||
message: t('auth.register.mobile_error'),
|
||||
trigger: ['change', 'blur'],
|
||||
}
|
||||
],
|
||||
mobile_code: {
|
||||
type: 'string',
|
||||
required: currentType.value === 'mobile' || configStore.login.is_bind_mobile,
|
||||
message: t('auth.register.code_placeholder'),
|
||||
trigger: ['blur', 'change']
|
||||
},
|
||||
captcha_code: {
|
||||
type: 'string',
|
||||
required: currentType.value === 'username',
|
||||
message: t('auth.register.captcha_placeholder'),
|
||||
trigger: ['blur', 'change'],
|
||||
}
|
||||
}))
|
||||
|
||||
// 协议同意状态
|
||||
const isAgreeChecked = ref(false)
|
||||
const toggleAgreement = () => {
|
||||
isAgreeChecked.value = !isAgreeChecked.value
|
||||
}
|
||||
|
||||
// 注册处理
|
||||
const handleRegister = async () => {
|
||||
await formRef.value?.validate(async (valid) => {
|
||||
if (!valid) return
|
||||
|
||||
// 检查协议同意
|
||||
if (configStore.login.agreement_show && !isAgreeChecked.value) {
|
||||
ElMessage.error(t('auth.register.is_agree_tips'))
|
||||
return
|
||||
}
|
||||
|
||||
if (isLoading.value) return
|
||||
isLoading.value = true
|
||||
|
||||
try {
|
||||
const registerApi = currentType.value === 'username' ? registerUser : registerWithMobile
|
||||
const data = await registerApi(formData) as any
|
||||
memberStore.setToken(data.access_token, data.refresh_token || '')
|
||||
memberStore.logClose()
|
||||
} catch (error) {
|
||||
isLoading.value = false
|
||||
captcha.refresh()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 验证码功能
|
||||
const captcha = useCaptcha(formData)
|
||||
captcha.refresh()
|
||||
|
||||
// 短信验证码
|
||||
const smsCodeRef = ref<AnyObject | null>(null)
|
||||
const sendSmsCode = async () => {
|
||||
await formRef.value?.validateField('mobile', (valid) => {
|
||||
if (valid) {
|
||||
smsCodeRef.value?.send()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 切换到登录
|
||||
const emit = defineEmits(['typeChange'])
|
||||
const switchToLogin = () => {
|
||||
emit('typeChange', 'login')
|
||||
}
|
||||
|
||||
// 输入框状态
|
||||
const realNameInput = ref(true)
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.register-container {
|
||||
width: 100%;
|
||||
|
||||
&.dark-mode {
|
||||
background: var(--el-bg-color);
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.register-form-container {
|
||||
background: var(--el-bg-color);
|
||||
width: 100%;
|
||||
padding: 60px 30px;
|
||||
border-radius: var(--rounded-big);
|
||||
}
|
||||
|
||||
.register-type-tabs {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: center;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.register-type-tab {
|
||||
font-size: 18px;
|
||||
cursor: pointer;
|
||||
color: var(--el-text-color-secondary);
|
||||
line-height: 24px;
|
||||
font-family: 'OppoSans-R';
|
||||
|
||||
&--active {
|
||||
color: var(--el-text-color-primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
&--margin {
|
||||
margin-right: 70px;
|
||||
}
|
||||
}
|
||||
|
||||
.register-form {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.form-item {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.input-container {
|
||||
flex: 1;
|
||||
height: 50px;
|
||||
border: 1px solid var(--el-border-color);
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background: var(--el-bg-color);
|
||||
}
|
||||
|
||||
.register-input {
|
||||
width: 100%;
|
||||
border: none;
|
||||
|
||||
:deep(.el-input__wrapper) {
|
||||
box-shadow: none;
|
||||
background: transparent;
|
||||
|
||||
.el-input__inner {
|
||||
color: var(--el-text-color-primary);
|
||||
|
||||
&::placeholder {
|
||||
color: var(--el-text-color-placeholder);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.input-prefix-icon {
|
||||
margin-right: 14px;
|
||||
}
|
||||
|
||||
.captcha-image-container {
|
||||
padding: 0;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.captcha-image {
|
||||
height: 30px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.login-link {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.register-button-container {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.register-button {
|
||||
width: 100%;
|
||||
height: 50px;
|
||||
border-radius: 8px;
|
||||
font-family: 'OppoSans-M';
|
||||
}
|
||||
|
||||
.agreement-container {
|
||||
font-size: 12px;
|
||||
line-height: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
margin-top: 20px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
.agreement-checkbox {
|
||||
color: var(--el-color-primary);
|
||||
margin-right: 5px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.agreement-link {
|
||||
color: var(--el-color-primary);
|
||||
margin: 0 4px;
|
||||
|
||||
&:hover {
|
||||
color: var(--el-color-primary-light-3);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,154 @@
|
||||
<template>
|
||||
<div v-if="configStore.search.keyword_enabled" class="search-box">
|
||||
<form
|
||||
:action="getSearchUrl()"
|
||||
method="get"
|
||||
target="_blank"
|
||||
class="search-form"
|
||||
@submit="handleSubmit"
|
||||
>
|
||||
<input
|
||||
v-model="searchKeyword"
|
||||
type="text"
|
||||
:name="getInputName()"
|
||||
:placeholder="placeholder"
|
||||
class="search-input"
|
||||
@input="handleInput"
|
||||
/>
|
||||
<button type="submit" class="search-button">
|
||||
<Icon icon="ant-design:search-outlined" />
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { Icon } from '~/components/icon'
|
||||
import { useConfigStore } from '~/stores/config'
|
||||
|
||||
interface Props {
|
||||
placeholder?: string
|
||||
defaultValue?: string
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
placeholder: '搜索...',
|
||||
defaultValue: ''
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
search: [keyword: string]
|
||||
input: [keyword: string]
|
||||
}>()
|
||||
|
||||
const configStore = useConfigStore()
|
||||
const searchKeyword = ref(props.defaultValue)
|
||||
|
||||
// 获取搜索URL
|
||||
const getSearchUrl = (): string => {
|
||||
if (configStore.search.keyword) {
|
||||
// 如果配置了关键字,使用关键字搜索
|
||||
return 'https://www.baidu.com/s'
|
||||
}
|
||||
return '#'
|
||||
}
|
||||
|
||||
// 获取输入框name属性
|
||||
const getInputName = (): string => {
|
||||
if (configStore.search.keyword) {
|
||||
return 'wd' // 百度搜索使用wd参数
|
||||
}
|
||||
return 'q'
|
||||
}
|
||||
|
||||
// 处理输入事件
|
||||
const handleInput = () => {
|
||||
emit('input', searchKeyword.value)
|
||||
}
|
||||
|
||||
// 处理提交事件
|
||||
const handleSubmit = (e: Event) => {
|
||||
e.preventDefault()
|
||||
emit('search', searchKeyword.value)
|
||||
|
||||
// 如果没有配置关键字,阻止默认提交行为
|
||||
if (!configStore.search.keyword) {
|
||||
e.preventDefault()
|
||||
}
|
||||
}
|
||||
|
||||
// 初始化
|
||||
onMounted(() => {
|
||||
// 配置已在 app.vue 或系统初始化时加载
|
||||
// 这里不需要额外调用
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.search-box {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
.search-form {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
flex: 1;
|
||||
height: 36px;
|
||||
padding: 0 12px;
|
||||
border: 1px solid var(--el-border-color);
|
||||
border-radius: 4px;
|
||||
background-color: var(--el-fill-color-blank);
|
||||
color: var(--el-text-color-primary);
|
||||
font-size: 14px;
|
||||
outline: none;
|
||||
transition: all 0.3s ease;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--el-border-color-hover);
|
||||
}
|
||||
|
||||
&:focus {
|
||||
border-color: var(--el-color-primary);
|
||||
}
|
||||
|
||||
&::placeholder {
|
||||
color: var(--el-text-color-placeholder);
|
||||
}
|
||||
}
|
||||
|
||||
.search-button {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
background-color: var(--el-color-primary);
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.3s ease;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--el-color-primary-light-3);
|
||||
}
|
||||
|
||||
&:active {
|
||||
background-color: var(--el-color-primary-dark-2);
|
||||
}
|
||||
|
||||
svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,128 @@
|
||||
<template>
|
||||
<!-- SEO Meta 组件 - Nuxt 4.2+ 支持 -->
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, watchEffect } from 'vue'
|
||||
import { useHead, useRoute } from 'nuxt/app'
|
||||
import { useConfigStore } from '~/stores/config'
|
||||
|
||||
const route = useRoute()
|
||||
const configStore = useConfigStore()
|
||||
|
||||
// 从路由 meta 中读取页面自定义 SEO 配置
|
||||
const pageSeo = computed(() => ({
|
||||
// 支持 definePageMeta 和路由配置两种方式
|
||||
title: route.meta.title as string || '',
|
||||
description: route.meta.description as string || '',
|
||||
keywords: route.meta.keywords as string || '',
|
||||
image: route.meta.image as string || '',
|
||||
noIndex: route.meta.noIndex as boolean || false,
|
||||
noFollow: route.meta.noFollow as boolean || false
|
||||
}))
|
||||
|
||||
// SEO 配置数据 - 直接从 configStore 读取
|
||||
const seoConfig = computed(() => ({
|
||||
// 基础 SEO 配置
|
||||
title: configStore.siteSetting?.seo_title || 'madong通用快速开发框架',
|
||||
description: configStore.siteSetting?.seo_description || 'madong开源服务平台',
|
||||
keywords: configStore.siteSetting?.seo_keywords || ['madong', 'madong-admin', 'madong工作流'],
|
||||
siteName: configStore.siteSetting?.site_name || 'madong官网',
|
||||
siteUrl: configStore.siteSetting?.site_url || (typeof window !== 'undefined' ? window.location.origin : ''),
|
||||
shareImage: configStore.siteSetting?.share_image || '/images/share-default.png'
|
||||
}))
|
||||
|
||||
// 组合 SEO 标题(页面标题优先)
|
||||
const seoTitle = computed(() => {
|
||||
if (pageSeo.value.title) {
|
||||
return `${pageSeo.value.title} - ${seoConfig.value.siteName}`
|
||||
}
|
||||
return seoConfig.value.title
|
||||
})
|
||||
|
||||
// 组合 SEO 描述(页面描述优先)
|
||||
const seoDescription = computed(() => {
|
||||
return pageSeo.value.description || seoConfig.value.description
|
||||
})
|
||||
|
||||
// 组合 SEO 关键词(页面关键词优先)
|
||||
const seoKeywords = computed(() => {
|
||||
const baseKeywords = Array.isArray(seoConfig.value.keywords)
|
||||
? seoConfig.value.keywords.join(',')
|
||||
: seoConfig.value.keywords
|
||||
|
||||
if (pageSeo.value.keywords) {
|
||||
return `${pageSeo.value.keywords},${baseKeywords}`
|
||||
}
|
||||
return baseKeywords
|
||||
})
|
||||
|
||||
// 组合分享图片(页面图片优先)
|
||||
const seoImage = computed(() => {
|
||||
return pageSeo.value.image || seoConfig.value.shareImage
|
||||
})
|
||||
|
||||
// 构建完整的 SEO 元数据数组
|
||||
const seoMetaArray = computed(() => {
|
||||
const metaArray = [
|
||||
// 基础 SEO Meta
|
||||
{ name: 'description', content: seoDescription.value },
|
||||
{ name: 'keywords', content: seoKeywords.value },
|
||||
{ name: 'author', content: seoConfig.value.siteName },
|
||||
|
||||
// Open Graph (社交媒体分享)
|
||||
{ property: 'og:title', content: seoTitle.value },
|
||||
{ property: 'og:description', content: seoDescription.value },
|
||||
{ property: 'og:type', content: 'website' },
|
||||
{ property: 'og:url', content: `${seoConfig.value.siteUrl}${route.path}` },
|
||||
{ property: 'og:image', content: seoImage.value },
|
||||
{ property: 'og:site_name', content: seoConfig.value.siteName },
|
||||
|
||||
// Twitter Card
|
||||
{ name: 'twitter:card', content: 'summary_large_image' },
|
||||
{ name: 'twitter:title', content: seoTitle.value },
|
||||
{ name: 'twitter:description', content: seoDescription.value },
|
||||
{ name: 'twitter:image', content: seoImage.value }
|
||||
]
|
||||
|
||||
// 添加 robots meta(如果需要)
|
||||
if (pageSeo.value.noIndex || pageSeo.value.noFollow) {
|
||||
const robotsDirectives = []
|
||||
if (pageSeo.value.noIndex) robotsDirectives.push('noindex')
|
||||
if (pageSeo.value.noFollow) robotsDirectives.push('nofollow')
|
||||
metaArray.push({ name: 'robots', content: robotsDirectives.join(',') })
|
||||
}
|
||||
|
||||
return metaArray
|
||||
})
|
||||
|
||||
// 链接标签数组
|
||||
const linkArray = computed(() => [
|
||||
// Canonical URL
|
||||
{ rel: 'canonical', href: `${seoConfig.value.siteUrl}${route.path}` },
|
||||
// Favicon
|
||||
{ rel: 'icon', type: 'image/x-icon', href: '/favicon.ico' }
|
||||
])
|
||||
|
||||
// 直接使用 useHead,它会自动响应式更新
|
||||
const headData = computed(() => ({
|
||||
title: seoTitle.value,
|
||||
htmlAttrs: {
|
||||
lang: 'zh-CN'
|
||||
},
|
||||
meta: seoMetaArray.value,
|
||||
link: linkArray.value
|
||||
}))
|
||||
|
||||
useHead(headData)
|
||||
|
||||
// 调试日志
|
||||
watchEffect(() => {
|
||||
// console.log('SEO 数据更新:', {
|
||||
// title: seoTitle.value,
|
||||
// description: seoDescription.value,
|
||||
// keywords: seoKeywords.value,
|
||||
// siteSetting: configStore.siteSetting
|
||||
// })
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,280 @@
|
||||
<template>
|
||||
<div class="sms-code-container">
|
||||
<el-button
|
||||
type="primary"
|
||||
link
|
||||
:disabled="!canSendCode"
|
||||
@click="handleSendCode"
|
||||
class="sms-code-button"
|
||||
>
|
||||
{{ buttonText }}
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<el-dialog
|
||||
v-model="showCaptchaDialog"
|
||||
:title="t('captcha_title')"
|
||||
width="350px"
|
||||
:append-to-body="true"
|
||||
:align-center="true"
|
||||
class="captcha-dialog"
|
||||
>
|
||||
<el-form
|
||||
:model="captchaForm"
|
||||
ref="captchaFormRef"
|
||||
:rules="captchaRules"
|
||||
@submit.native.prevent
|
||||
class="captcha-form"
|
||||
>
|
||||
<el-form-item prop="captcha_code" class="captcha-form-item">
|
||||
<el-input
|
||||
v-model="captchaForm.captcha_code"
|
||||
:placeholder="t('captcha_placeholder')"
|
||||
class="captcha-input"
|
||||
>
|
||||
<template #suffix>
|
||||
<div class="captcha-image-wrapper">
|
||||
<el-image
|
||||
:src="captchaImage"
|
||||
class="captcha-image"
|
||||
@click="refreshCaptcha"
|
||||
></el-image>
|
||||
</div>
|
||||
</template>
|
||||
</el-input>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<span class="dialog-footer">
|
||||
<el-button @click="closeCaptchaDialog" class="cancel-button">
|
||||
{{ t('cancel') }}
|
||||
</el-button>
|
||||
<el-button
|
||||
type="primary"
|
||||
:loading="isLoading"
|
||||
@click="confirmSendCode"
|
||||
class="confirm-button"
|
||||
>
|
||||
{{ t('confirm') }}
|
||||
</el-button>
|
||||
</span>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, reactive, computed, watch } from 'vue'
|
||||
import { sendSmsVerificationCode } from '@/api/system'
|
||||
import type { FormInstance } from 'element-plus'
|
||||
|
||||
interface SmsCodeProps {
|
||||
mobile?: string
|
||||
email?: string
|
||||
type: string
|
||||
modelValue: string
|
||||
}
|
||||
|
||||
interface CaptchaFormData {
|
||||
mobile?: string
|
||||
email?: string
|
||||
captcha_code: string
|
||||
captcha_key: string
|
||||
type: string
|
||||
}
|
||||
|
||||
const props = defineProps<SmsCodeProps>()
|
||||
const emit = defineEmits(['update:modelValue', 'click'])
|
||||
|
||||
// 响应式数据
|
||||
const isLoading = ref(false)
|
||||
const showCaptchaDialog = ref(false)
|
||||
const canSendCode = ref(true)
|
||||
const buttonText = ref('')
|
||||
const seconds = ref(90)
|
||||
let countdownTimer: number | undefined = undefined
|
||||
|
||||
// 验证码相关
|
||||
const captchaForm = reactive<CaptchaFormData>({
|
||||
mobile: props.mobile || '',
|
||||
email: props.email || '',
|
||||
captcha_code: '',
|
||||
captcha_key: '',
|
||||
type: props.type
|
||||
})
|
||||
|
||||
// 监听 props 变化,更新 captchaForm
|
||||
watch(() => props.mobile, (newVal) => {
|
||||
captchaForm.mobile = newVal || ''
|
||||
})
|
||||
watch(() => props.email, (newVal) => {
|
||||
captchaForm.email = newVal || ''
|
||||
})
|
||||
|
||||
const captchaImage = ref('')
|
||||
const captchaFormRef = ref<FormInstance>()
|
||||
|
||||
// 验证规则
|
||||
const captchaRules = reactive({
|
||||
captcha_code: {
|
||||
required: true,
|
||||
message: '请输入验证码',
|
||||
trigger: ['blur', 'change']
|
||||
}
|
||||
})
|
||||
|
||||
// 计算属性
|
||||
const smsValue = computed({
|
||||
get() {
|
||||
return props.modelValue
|
||||
},
|
||||
set(value) {
|
||||
emit('update:modelValue', value)
|
||||
}
|
||||
})
|
||||
|
||||
// 初始化按钮文本
|
||||
buttonText.value = t('get_sms_code')
|
||||
|
||||
// 发送短信验证码
|
||||
const sendSmsCode = async () => {
|
||||
captchaForm.mobile = props.mobile || ''
|
||||
captchaForm.email = props.email || ''
|
||||
|
||||
if (!canSendCode.value) return
|
||||
|
||||
try {
|
||||
isLoading.value = true
|
||||
const response = await sendSmsVerificationCode(captchaForm) as any
|
||||
|
||||
if (response?.code !== -1) {
|
||||
smsValue.value = response.data.key
|
||||
startCountdown()
|
||||
closeCaptchaDialog()
|
||||
} else {
|
||||
refreshCaptcha()
|
||||
}
|
||||
} catch (error) {
|
||||
refreshCaptcha()
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 开始倒计时
|
||||
const startCountdown = () => {
|
||||
canSendCode.value = false
|
||||
countdownTimer = setInterval(() => {
|
||||
if (seconds.value > 0) {
|
||||
seconds.value -= 1
|
||||
buttonText.value = `${seconds.value}${t('sms_code_change_text')}`
|
||||
} else {
|
||||
resetCountdown()
|
||||
}
|
||||
}, 1000) as any
|
||||
}
|
||||
|
||||
// 重置倒计时
|
||||
const resetCountdown = () => {
|
||||
clearInterval(countdownTimer)
|
||||
seconds.value = 90
|
||||
canSendCode.value = true
|
||||
buttonText.value = t('get_sms_code')
|
||||
}
|
||||
|
||||
// 刷新验证码
|
||||
const refreshCaptcha = async () => {
|
||||
try {
|
||||
// 这里需要调用验证码API,暂时使用空字符串
|
||||
captchaImage.value = ''
|
||||
captchaForm.captcha_code = ''
|
||||
} catch (error) {
|
||||
console.error('刷新验证码失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 处理发送验证码点击
|
||||
const handleSendCode = () => {
|
||||
emit('click')
|
||||
showCaptchaDialog.value = true
|
||||
refreshCaptcha()
|
||||
}
|
||||
|
||||
// 确认发送验证码
|
||||
const confirmSendCode = async () => {
|
||||
await captchaFormRef.value?.validate(async (valid) => {
|
||||
if (valid) {
|
||||
await sendSmsCode()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 关闭验证码对话框
|
||||
const closeCaptchaDialog = () => {
|
||||
showCaptchaDialog.value = false
|
||||
captchaForm.captcha_code = ''
|
||||
}
|
||||
|
||||
// 暴露方法给父组件
|
||||
defineExpose({
|
||||
send: sendSmsCode
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.sms-code-container {
|
||||
height: 30px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.sms-code-button {
|
||||
font-size: 12px;
|
||||
padding: 0;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.captcha-dialog {
|
||||
:deep(.el-dialog__header) {
|
||||
padding: 20px 20px 10px;
|
||||
}
|
||||
|
||||
:deep(.el-dialog__body) {
|
||||
padding: 10px 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.captcha-form {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.captcha-form-item {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.captcha-input {
|
||||
:deep(.el-input__wrapper) {
|
||||
padding-right: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.captcha-image-wrapper {
|
||||
padding: 5px 0;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.captcha-image {
|
||||
height: 30px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.dialog-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.cancel-button,
|
||||
.confirm-button {
|
||||
min-width: 80px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,22 @@
|
||||
import { hasAuth } from '~/plugins/permission'
|
||||
|
||||
/**
|
||||
* 权限检查组合式函数
|
||||
*/
|
||||
export function useAuth() {
|
||||
return {
|
||||
/**
|
||||
* 检查用户是否有权限
|
||||
* @param permissions 权限码或权限码数组
|
||||
* @returns 是否有权限
|
||||
*/
|
||||
hasAuth,
|
||||
|
||||
/**
|
||||
* 检查用户是否没有权限
|
||||
* @param permissions 权限码或权限码数组
|
||||
* @returns 是否没有权限
|
||||
*/
|
||||
lacksAuth: (permissions: string | string[]) => !hasAuth(permissions)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { ref } from 'vue'
|
||||
import { getCaptchaImage } from '@/api/system'
|
||||
|
||||
interface CaptchaFormData {
|
||||
captcha_code: string
|
||||
captcha_key: string
|
||||
}
|
||||
|
||||
interface CaptchaResponse {
|
||||
code: number
|
||||
msg: string
|
||||
data: {
|
||||
uuid: string
|
||||
base64: string
|
||||
}
|
||||
}
|
||||
|
||||
export function useCaptcha(formData: CaptchaFormData) {
|
||||
const image = ref('')
|
||||
|
||||
const refresh = async () => {
|
||||
try {
|
||||
const res = await getCaptchaImage() as CaptchaResponse
|
||||
if (res.code === 0 && res.data) {
|
||||
formData.captcha_key = res.data.uuid
|
||||
formData.captcha_code = ''
|
||||
image.value = res.data.base64.replace(/\r\n/g, '')
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('获取验证码失败:', e)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
image,
|
||||
refresh
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import { useRoute } from 'vue-router'
|
||||
|
||||
export function t(message: string) {
|
||||
const nuxtApp: any = useNuxtApp()
|
||||
const i18nInstance = nuxtApp.$getI18n()
|
||||
|
||||
// 直接使用 i18n 的 t 方法,让它自动根据 locale 选择对应的翻译
|
||||
return i18nInstance.global.t(message)
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { LocationQueryRaw } from 'vue-router'
|
||||
import storage from '@/utils/storage'
|
||||
|
||||
export function useLogin() {
|
||||
/**
|
||||
* 设置登录返回页
|
||||
*/
|
||||
const setLoginBack = (route: LocationQueryRaw) => {
|
||||
storage.set({
|
||||
key: 'loginBack',
|
||||
data: {
|
||||
path: route.path,
|
||||
query: route.query
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行登录后跳转
|
||||
*/
|
||||
const handleLoginBack = (callbak:any) => {
|
||||
if(callbak) callbak()
|
||||
}
|
||||
|
||||
return {
|
||||
setLoginBack,
|
||||
handleLoginBack
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { reactive, ref } from 'vue'
|
||||
import { sendSmsVerificationCode } from '@/api/system'
|
||||
|
||||
export function useSendSms() {
|
||||
const canGetCode = ref(true),
|
||||
text = ref(t('get_sms_code')),
|
||||
seconds = ref(90);
|
||||
|
||||
let timer: number | undefined = undefined;
|
||||
|
||||
const send = async (param: requestMobileParam) => {
|
||||
if (!canGetCode.value) return
|
||||
canGetCode.value = false
|
||||
|
||||
let result: string | boolean = false
|
||||
await sendSmsVerificationCode(param).then((res: any) => {
|
||||
if (res.code == 1) {
|
||||
start()
|
||||
result = res.data.key
|
||||
} else {
|
||||
reset()
|
||||
result = false
|
||||
}
|
||||
}).catch(() => {
|
||||
result = false
|
||||
reset()
|
||||
})
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* 开始
|
||||
*/
|
||||
const start = () => {
|
||||
timer = setInterval(() => {
|
||||
if (seconds.value > 0) {
|
||||
seconds.value -= 1
|
||||
text.value = `${seconds.value}${t('sms_code_change_text')}`
|
||||
} else {
|
||||
reset()
|
||||
}
|
||||
}, 1000) as any
|
||||
}
|
||||
|
||||
/**
|
||||
* 重置
|
||||
*/
|
||||
const reset = () => {
|
||||
clearInterval(timer)
|
||||
seconds.value = 90
|
||||
canGetCode.value = true
|
||||
text.value = t('getSmsCode')
|
||||
}
|
||||
|
||||
return {
|
||||
send,
|
||||
text,
|
||||
canGetCode
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"title": "Forgot Password",
|
||||
"tip": "Please enter your email, we will send a verification code to your email",
|
||||
"step2_title": "Set New Password",
|
||||
"step2_tip": "Please enter your new password",
|
||||
"email_placeholder": "Please enter email",
|
||||
"email_error": "Please enter a valid email",
|
||||
"captcha_placeholder": "Please enter image verification code",
|
||||
"email_code_placeholder": "Please enter email verification code",
|
||||
"code_placeholder": "Please enter verification code",
|
||||
"send_code": "Send Code",
|
||||
"code_sent": "Verification code sent",
|
||||
"new_password_placeholder": "Please enter new password",
|
||||
"confirm_password_placeholder": "Please confirm password",
|
||||
"confirm_password_error": "Passwords do not match",
|
||||
"reset": "Reset Password",
|
||||
"resetting": "Resetting",
|
||||
"reset_success": "Password reset successfully",
|
||||
"back_to_login": "Back to login",
|
||||
"next_step": "Next Step",
|
||||
"prev_step": "Previous Step",
|
||||
"verifying": "Verifying",
|
||||
"captcha_alt": "Verification code",
|
||||
"reset_title": "Reset Password",
|
||||
"verification_sent": "Verification code has been sent to {email}",
|
||||
"verification_instruction": "Please enter the verification code to complete password reset",
|
||||
"password_placeholder": "Password",
|
||||
"verification_code_placeholder": "Enter verification code",
|
||||
"resend_code": "Resend",
|
||||
"reset_password": "Reset Password",
|
||||
"email_required": "Please enter email",
|
||||
"email_invalid": "Invalid email format",
|
||||
"captcha_required": "Please enter image verification code",
|
||||
"password_required": "Please enter password",
|
||||
"confirm_password_required": "Please confirm password",
|
||||
"password_mismatch": "Passwords do not match",
|
||||
"verification_code_required": "Please enter verification code",
|
||||
"code_sent_check_email": "Verification code has been sent, please check your email",
|
||||
"login": "Login"
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"login": "Login",
|
||||
"logining": "Logging in",
|
||||
"username_placeholder": "Please enter username",
|
||||
"password_placeholder": "Please enter password",
|
||||
"resetpwd": "Forgot Password",
|
||||
"no_account": "Don't have an account",
|
||||
"to_register": "Register",
|
||||
"and": "and",
|
||||
"agree_tips": "By logging in, you agree to",
|
||||
"username_login": "Password Login",
|
||||
"mobile_login": "SMS Code Login",
|
||||
"mobile_placeholder": "Please enter mobile number",
|
||||
"wechat_scan_login": "Scan QR Code Login",
|
||||
"wechat_scan_tips": "Please scan QR code with WeChat to login",
|
||||
"qrcode_generate_failed": "Failed to generate QR code, please refresh and try again",
|
||||
"click_refresh": "Click to refresh",
|
||||
"wechat_scan_tip": "Please scan QR code with WeChat to login",
|
||||
"wechat_login_not_available": "WeChat scan login is not available"
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"title": "Register",
|
||||
"username_register": "Username Register",
|
||||
"mobile_register": "Mobile Register",
|
||||
"username_placeholder": "Please enter username",
|
||||
"email_placeholder": "Please enter email",
|
||||
"email_required": "Please enter email",
|
||||
"email_error": "Invalid email format",
|
||||
"password_placeholder": "Please enter password",
|
||||
"confirm_password_placeholder": "Please confirm password",
|
||||
"confirm_password_error": "Passwords do not match",
|
||||
"mobile_placeholder": "Please enter mobile phone",
|
||||
"mobile_error": "Invalid mobile phone format",
|
||||
"code_placeholder": "Please enter verification code",
|
||||
"captcha_placeholder": "Please enter verification code",
|
||||
"register": "Register",
|
||||
"registering": "Registering",
|
||||
"have_account": "Already have an account",
|
||||
"to_login": "Go to login",
|
||||
"register_agree_tips": "Registering means you agree to",
|
||||
"user_agreement": "User Agreement",
|
||||
"privacy_agreement": "Privacy Agreement",
|
||||
"and": "and",
|
||||
"is_agree_tips": "Please read and agree to the User Agreement and Privacy Agreement"
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"language": "Language",
|
||||
"logout_login": "Logout",
|
||||
"member_center": "Member Center",
|
||||
"login": "Login",
|
||||
"register": "Register",
|
||||
"user": "User",
|
||||
"get_sms_code": "Get SMS Code",
|
||||
"sms_code_change_text": "seconds to resend",
|
||||
"captcha_title": "Please complete security verification",
|
||||
"confirm": "Confirm",
|
||||
"cancel": "Cancel",
|
||||
"siteClose": "Site Closed",
|
||||
"setting": {
|
||||
"config": {
|
||||
"common": {
|
||||
"title": "Common Config",
|
||||
"description": "Common Config - Key-Value format with hierarchical data support",
|
||||
"noData": "No Data",
|
||||
"viewData": "View Data"
|
||||
}
|
||||
}
|
||||
},
|
||||
"request": {
|
||||
"unknownError": "Unknown Error",
|
||||
"400": "Bad Request",
|
||||
"401": "Please Login Again",
|
||||
"403": "Access Denied",
|
||||
"404": "Request Error",
|
||||
"405": "Method Not Allowed",
|
||||
"408": "Request Timeout",
|
||||
"409": "Request Cross-Origin",
|
||||
"500": "Server Error, Reason:",
|
||||
"501": "Network Not Implemented",
|
||||
"502": "Network Error",
|
||||
"503": "Service Unavailable",
|
||||
"504": "Network Timeout",
|
||||
"505": "HTTP Version Not Supported",
|
||||
"timeout": "Network Request Timeout!"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"page_title": "Balance Record",
|
||||
"current_balance": "Current Balance",
|
||||
"total_transaction": "Total Transaction",
|
||||
"record_id": "Record ID",
|
||||
"type": "Type",
|
||||
"type_income": "Income",
|
||||
"type_expense": "Expense",
|
||||
"amount": "Amount",
|
||||
"description": "Description",
|
||||
"time": "Time",
|
||||
"loading": "Loading",
|
||||
"no_data": "No Data",
|
||||
"recharge": "Recharge",
|
||||
"withdraw": "Withdraw"
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"page_title": "Change Password",
|
||||
"current_password": "Current Password",
|
||||
"current_password_placeholder": "Please enter current password",
|
||||
"new_password": "New Password",
|
||||
"new_password_placeholder": "Please enter new password",
|
||||
"confirm_password": "Confirm New Password",
|
||||
"confirm_password_placeholder": "Please confirm new password",
|
||||
"password_error": "Passwords do not match",
|
||||
"submit_button": "Submit",
|
||||
"reset_button": "Reset",
|
||||
"success": "Password changed successfully",
|
||||
"failed": "Password change failed",
|
||||
"validation": {
|
||||
"current_password_required": "Please enter current password",
|
||||
"new_password_required": "Please enter new password",
|
||||
"new_password_min_length": "Password must be at least 6 characters",
|
||||
"confirm_password_required": "Please confirm new password",
|
||||
"confirm_password_mismatch": "Passwords do not match"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"page_title": "Points Record",
|
||||
"current_points": "Current Points",
|
||||
"monthly_points": "This Month Earned",
|
||||
"total_points": "Total Earned",
|
||||
"record_id": "Record ID",
|
||||
"type": "Type",
|
||||
"type_earn": "Earned",
|
||||
"type_deduct": "Deducted",
|
||||
"points": "Points",
|
||||
"reason": "Reason",
|
||||
"time": "Time",
|
||||
"loading": "Loading",
|
||||
"no_data": "No Data"
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"page_title": "Profile",
|
||||
"username": "Username",
|
||||
"email": "Email",
|
||||
"email_placeholder": "Please enter email",
|
||||
"phone": "Mobile Number",
|
||||
"nickname": "Nickname",
|
||||
"nickname_placeholder": "Please enter nickname",
|
||||
"gender": "Gender",
|
||||
"gender_male": "Male",
|
||||
"gender_female": "Female",
|
||||
"gender_other": "Other",
|
||||
"birthday": "Birthday",
|
||||
"birthday_placeholder": "Please select birthday",
|
||||
"bio": "Bio",
|
||||
"bio_placeholder": "Please enter bio",
|
||||
"save_button": "Save",
|
||||
"reset_button": "Reset",
|
||||
"avatar": "Avatar",
|
||||
"upload_avatar": "Upload Avatar",
|
||||
"update_success": "Profile saved successfully",
|
||||
"update_failed": "Save failed",
|
||||
"validation": {
|
||||
"nickname_required": "Please enter nickname",
|
||||
"email_format": "Please enter correct email address"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
{
|
||||
"page_title": "User Settings",
|
||||
"account_info": "Account Information",
|
||||
"login_phone": "Login Mobile",
|
||||
"change_phone": "Change Number",
|
||||
"email": "Email",
|
||||
"change_email": "Change Email",
|
||||
"login_password": "Login Password",
|
||||
"change_password": "Change Password",
|
||||
"third_party_bind": "Third Party Account Binding",
|
||||
"bind": "Bind",
|
||||
"bind_time": "Bind Time",
|
||||
"status": "Status",
|
||||
"options": "Options",
|
||||
"unbind": "Unbind",
|
||||
"bind_success": "Binding successful",
|
||||
"unbind_success": "Unbinding successful",
|
||||
"bind_failed": "Binding failed",
|
||||
"unbind_failed": "Unbinding failed",
|
||||
"confirm_unbind": "Are you sure you want to unbind {name}?",
|
||||
"wechat": "WeChat",
|
||||
"qq": "QQ",
|
||||
"weibo": "Weibo",
|
||||
"douyin": "Douyin",
|
||||
"using": "Using",
|
||||
"not_bound": "Not Bound",
|
||||
"phone_bind": "Mobile Number Binding",
|
||||
"phone_bind_dialog": "Change Mobile Number",
|
||||
"old_phone": "Old Mobile Number",
|
||||
"old_phone_placeholder": "Please enter old mobile number",
|
||||
"new_phone": "New Mobile Number",
|
||||
"new_phone_placeholder": "Please enter new mobile number",
|
||||
"verification_code": "Verification Code",
|
||||
"verification_code_placeholder": "Please enter verification code",
|
||||
"get_code": "Get Code",
|
||||
"confirm": "Confirm",
|
||||
"cancel": "Cancel",
|
||||
"email_bind": "Email Binding",
|
||||
"email_bind_dialog": "Change Email",
|
||||
"new_email": "New Email",
|
||||
"new_email_placeholder": "Please enter new email",
|
||||
"qr_bind_title": "Bind {name}",
|
||||
"qr_generating": "Generating QR code...",
|
||||
"qr_expired": "QR code expired",
|
||||
"qr_regenerate": "Regenerate",
|
||||
"qr_success": "Binding successful!",
|
||||
"qr_tip": "Please scan QR code with {name} to bind",
|
||||
"phone_update_success": "Mobile number updated successfully",
|
||||
"phone_update_failed": "Mobile number update failed",
|
||||
"email_update_success": "Email updated successfully",
|
||||
"email_update_failed": "Email update failed",
|
||||
"code_send_success": "Verification code sent successfully",
|
||||
"code_send_failed": "Verification code send failed",
|
||||
"please_enter_phone": "Please enter new mobile number",
|
||||
"please_enter_email": "Please enter email",
|
||||
"please_enter_old_phone": "Please enter old mobile number",
|
||||
"please_enter_code": "Please enter verification code",
|
||||
"confirm_unbind_title": "Confirm",
|
||||
"cancel_unbind": "Unbinding cancelled",
|
||||
"opening_app": "Opening {name}...",
|
||||
"open_app_failed": "Failed to open {name}, please ensure {name} client is installed",
|
||||
"please_bind": "Please click to bind {name}",
|
||||
"qr_generate_failed": "Failed to generate QR code",
|
||||
"validation": {
|
||||
"old_phone_required": "Please enter old mobile number",
|
||||
"new_phone_required": "Please enter new mobile number",
|
||||
"code_required": "Please enter verification code",
|
||||
"email_required": "Please enter email",
|
||||
"new_email_required": "Please enter new email",
|
||||
"new_email_code_required": "Please enter verification code"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"page_title": "Daily Check-in",
|
||||
"sign_rules": "Check-in Rules",
|
||||
"sign_button": "Check in",
|
||||
"signed": "Already Checked in",
|
||||
"consecutive_days": "Consecutive Check-in",
|
||||
"days": "days",
|
||||
"tomorrow_points": "Continue check-in tomorrow to get {points} points~",
|
||||
"sign_success": "Check-in successful! Got {points} points",
|
||||
"sign_failed": "Check-in failed",
|
||||
"get_sign_status_failed": "Failed to get check-in status",
|
||||
"get_sign_calendar_failed": "Failed to get check-in calendar",
|
||||
"rules_content": "Check-in Rules:\n1. Daily check-in can get 1-5 points\n2. The more consecutive check-in days, the more points you get\n3. Consecutive check-in for 7 days can get extra rewards"
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
{
|
||||
"title": "忘记密码",
|
||||
"tip": "请输入您的邮箱,我们将发送验证码到您的邮箱",
|
||||
"step2_title": "设置新密码",
|
||||
"step2_tip": "请输入您的新密码",
|
||||
"email_placeholder": "请输入邮箱",
|
||||
"email_error": "请输入正确的邮箱",
|
||||
"captcha_placeholder": "请输入图片验证码",
|
||||
"email_code_placeholder": "请输入邮箱验证码",
|
||||
"code_placeholder": "请输入验证码",
|
||||
"send_code": "发送验证码",
|
||||
"code_sent": "验证码已发送",
|
||||
"new_password_placeholder": "请输入新密码",
|
||||
"confirm_password_placeholder": "请确认新密码",
|
||||
"confirm_password_error": "两次输入的密码不一致",
|
||||
"reset": "重置密码",
|
||||
"resetting": "重置中",
|
||||
"reset_success": "密码重置成功",
|
||||
"back_to_login": "返回登录",
|
||||
"next_step": "下一步",
|
||||
"prev_step": "上一步",
|
||||
"verifying": "验证中",
|
||||
"email_placeholder": "输入邮箱",
|
||||
"captcha_placeholder": "输入图形验证码",
|
||||
"captcha_alt": "验证码",
|
||||
"reset_title": "重置密码",
|
||||
"verification_sent": "验证码已经发送至{email}",
|
||||
"verification_instruction": "请将验证码填写到此表单中完成密码重置",
|
||||
"password_placeholder": "密码",
|
||||
"confirm_password_placeholder": "重复密码",
|
||||
"verification_code_placeholder": "输入验证码",
|
||||
"resend_code": "重新发送",
|
||||
"reset_password": "重置密码",
|
||||
"email_required": "请输入邮箱",
|
||||
"email_invalid": "邮箱格式不正确",
|
||||
"captcha_required": "请输入图形验证码",
|
||||
"password_required": "请输入密码",
|
||||
"confirm_password_required": "请确认密码",
|
||||
"password_mismatch": "两次密码输入不一致",
|
||||
"verification_code_required": "请输入验证码",
|
||||
"code_sent_check_email": "验证码已发送,请查收",
|
||||
"back_to_login": "返回登录",
|
||||
"login": "登录",
|
||||
"next_step": "下一步",
|
||||
"prev_step": "上一步",
|
||||
"code_sent": "验证码已发送"
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"login": "登录",
|
||||
"logining": "登录中",
|
||||
"username_placeholder": "请输入账号",
|
||||
"password_placeholder": "请输入密码",
|
||||
"resetpwd": "忘记密码",
|
||||
"no_account": "还没有账号",
|
||||
"to_register": "去注册",
|
||||
"and": "和",
|
||||
"agree_tips": "登录代表您同意",
|
||||
"username_login": "密码登录",
|
||||
"mobile_login": "验证码登录",
|
||||
"mobile_placeholder": "请输入手机号",
|
||||
"wechat_scan_login": "扫码登录",
|
||||
"wechat_scan_tips": "请使用微信扫描二维码登录",
|
||||
"qrcode_generate_failed": "二维码生成失败,请刷新重试",
|
||||
"click_refresh": "点击刷新",
|
||||
"wechat_scan_tip": "请使用微信扫描二维码登录",
|
||||
"wechat_login_not_available": "未开通微信扫描登录"
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"title": "注册",
|
||||
"username_register": "用户名注册",
|
||||
"mobile_register": "手机号注册",
|
||||
"username_placeholder": "请输入用户名",
|
||||
"email_placeholder": "请输入邮箱",
|
||||
"email_required": "请输入邮箱",
|
||||
"email_error": "邮箱格式不正确",
|
||||
"password_placeholder": "请输入密码",
|
||||
"confirm_password_placeholder": "请确认密码",
|
||||
"confirm_password_error": "两次输入的密码不一致",
|
||||
"mobile_placeholder": "请输入手机号",
|
||||
"mobile_error": "手机号格式不正确",
|
||||
"code_placeholder": "请输入验证码",
|
||||
"captcha_placeholder": "请输入验证码",
|
||||
"register": "注册",
|
||||
"registering": "注册中",
|
||||
"have_account": "已有账号",
|
||||
"to_login": "去登录",
|
||||
"register_agree_tips": "注册即表示同意",
|
||||
"user_agreement": "用户协议",
|
||||
"privacy_agreement": "隐私协议",
|
||||
"and": "和",
|
||||
"is_agree_tips": "请阅读并同意用户协议和隐私协议"
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"language": "语言",
|
||||
"logout_login": "注销登录",
|
||||
"member_center": "个人中心",
|
||||
"login": "登录",
|
||||
"register": "注册",
|
||||
"user": "用户",
|
||||
"get_sms_code": "获取短信验证码",
|
||||
"sms_code_change_text": "秒后重新获取",
|
||||
"captcha_title": "请先完成安全验证",
|
||||
"confirm": "确认",
|
||||
"cancel": "取消",
|
||||
"siteClose": "站点已关闭",
|
||||
"setting": {
|
||||
"config": {
|
||||
"common": {
|
||||
"title": "通用配置",
|
||||
"description": "通用配置 - Key-Value 形式存储,支持层级数据",
|
||||
"noData": "暂无数据",
|
||||
"viewData": "查看数据"
|
||||
}
|
||||
}
|
||||
},
|
||||
"request": {
|
||||
"unknownError": "未知错误",
|
||||
"400": "错误的请求",
|
||||
"401": "请重新登录",
|
||||
"403": "拒绝访问",
|
||||
"404": "请求错误",
|
||||
"405": "请求方法未允许",
|
||||
"408": "请求超时",
|
||||
"409": "请求跨域",
|
||||
"500": "服务器端出错,错误原因:",
|
||||
"501": "网络未实现",
|
||||
"502": "网络错误",
|
||||
"503": "服务不可用",
|
||||
"504": "网络超时",
|
||||
"505": "http版本不支持该请求",
|
||||
"timeout": "网络请求超时!"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"page_title": "余额记录",
|
||||
"current_balance": "当前余额",
|
||||
"total_transaction": "总交易额",
|
||||
"record_id": "记录ID",
|
||||
"type": "类型",
|
||||
"type_income": "收入",
|
||||
"type_expense": "支出",
|
||||
"amount": "金额",
|
||||
"description": "描述",
|
||||
"time": "时间",
|
||||
"loading": "加载中",
|
||||
"no_data": "暂无数据",
|
||||
"recharge": "充值",
|
||||
"withdraw": "提现"
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"page_title": "修改密码",
|
||||
"current_password": "当前密码",
|
||||
"current_password_placeholder": "请输入当前密码",
|
||||
"new_password": "新密码",
|
||||
"new_password_placeholder": "请输入新密码",
|
||||
"confirm_password": "确认新密码",
|
||||
"confirm_password_placeholder": "请确认新密码",
|
||||
"password_error": "两次输入的密码不一致",
|
||||
"submit_button": "提交修改",
|
||||
"reset_button": "重置",
|
||||
"success": "密码修改成功",
|
||||
"failed": "密码修改失败",
|
||||
"validation": {
|
||||
"current_password_required": "请输入当前密码",
|
||||
"new_password_required": "请输入新密码",
|
||||
"new_password_min_length": "密码长度不能少于6位",
|
||||
"confirm_password_required": "请确认新密码",
|
||||
"confirm_password_mismatch": "两次输入的密码不一致"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"page_title": "积分记录",
|
||||
"current_points": "当前积分",
|
||||
"monthly_points": "本月获得",
|
||||
"total_points": "总获得",
|
||||
"record_id": "记录ID",
|
||||
"type": "类型",
|
||||
"type_earn": "获得",
|
||||
"type_deduct": "扣除",
|
||||
"points": "积分",
|
||||
"reason": "原因",
|
||||
"time": "时间",
|
||||
"loading": "加载中",
|
||||
"no_data": "暂无数据"
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"page_title": "个人资料",
|
||||
"username": "用户名",
|
||||
"email": "邮箱",
|
||||
"email_placeholder": "请输入邮箱",
|
||||
"phone": "手机号",
|
||||
"nickname": "昵称",
|
||||
"nickname_placeholder": "请输入昵称",
|
||||
"gender": "性别",
|
||||
"gender_male": "男",
|
||||
"gender_female": "女",
|
||||
"gender_other": "其他",
|
||||
"birthday": "生日",
|
||||
"birthday_placeholder": "请选择生日",
|
||||
"bio": "个人简介",
|
||||
"bio_placeholder": "请输入个人简介",
|
||||
"save_button": "保存修改",
|
||||
"reset_button": "重置",
|
||||
"avatar": "头像",
|
||||
"upload_avatar": "上传头像",
|
||||
"update_success": "个人资料保存成功",
|
||||
"update_failed": "保存失败",
|
||||
"validation": {
|
||||
"nickname_required": "请输入昵称",
|
||||
"email_format": "请输入正确的邮箱地址"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
{
|
||||
"page_title": "用户设置",
|
||||
"account_info": "账户信息",
|
||||
"login_phone": "登录手机",
|
||||
"change_phone": "更换号码",
|
||||
"email": "邮箱",
|
||||
"change_email": "修改邮箱",
|
||||
"login_password": "登录密码",
|
||||
"change_password": "修改密码",
|
||||
"third_party_bind": "第三方账号绑定",
|
||||
"bind": "绑定",
|
||||
"bind_time": "绑定时间",
|
||||
"status": "状态",
|
||||
"options": "操作",
|
||||
"unbind": "解绑",
|
||||
"bind_success": "绑定成功",
|
||||
"unbind_success": "解绑成功",
|
||||
"bind_failed": "绑定失败",
|
||||
"unbind_failed": "解绑失败",
|
||||
"confirm_unbind": "确定要解绑{name}吗?",
|
||||
"wechat": "微信",
|
||||
"qq": "QQ",
|
||||
"weibo": "微博",
|
||||
"douyin": "抖音",
|
||||
"using": "使用中",
|
||||
"not_bound": "未绑定",
|
||||
"phone_bind": "手机号绑定",
|
||||
"phone_bind_dialog": "更换手机号码",
|
||||
"old_phone": "旧手机号",
|
||||
"old_phone_placeholder": "请输入旧手机号",
|
||||
"new_phone": "新手机号",
|
||||
"new_phone_placeholder": "请输入新手机号",
|
||||
"verification_code": "验证码",
|
||||
"verification_code_placeholder": "请输入验证码",
|
||||
"get_code": "获取验证码",
|
||||
"confirm": "确定",
|
||||
"cancel": "取消",
|
||||
"email_bind": "邮箱绑定",
|
||||
"email_bind_dialog": "修改邮箱",
|
||||
"new_email": "新邮箱",
|
||||
"new_email_placeholder": "请输入新邮箱",
|
||||
"qr_bind_title": "绑定{name}",
|
||||
"qr_generating": "正在生成二维码...",
|
||||
"qr_expired": "二维码已过期",
|
||||
"qr_regenerate": "重新生成",
|
||||
"qr_success": "绑定成功!",
|
||||
"qr_tip": "请使用{name}扫描二维码进行绑定",
|
||||
"phone_update_success": "手机号更新成功",
|
||||
"phone_update_failed": "手机号更新失败",
|
||||
"email_update_success": "邮箱更新成功",
|
||||
"email_update_failed": "邮箱更新失败",
|
||||
"code_send_success": "验证码发送成功",
|
||||
"code_send_failed": "验证码发送失败",
|
||||
"please_enter_phone": "请输入新手机号",
|
||||
"please_enter_email": "请输入邮箱",
|
||||
"please_enter_old_phone": "请输入旧手机号",
|
||||
"please_enter_code": "请输入验证码",
|
||||
"confirm_unbind_title": "提示",
|
||||
"cancel_unbind": "已取消解绑",
|
||||
"opening_app": "正在唤起{name}...",
|
||||
"open_app_failed": "唤起{name}失败,请确保已安装{name}客户端",
|
||||
"please_bind": "请点击绑定{name}",
|
||||
"qr_generate_failed": "生成二维码失败",
|
||||
"validation": {
|
||||
"old_phone_required": "请输入旧手机号",
|
||||
"new_phone_required": "请输入新手机号",
|
||||
"code_required": "请输入验证码",
|
||||
"email_required": "请输入邮箱",
|
||||
"new_email_required": "请输入新邮箱",
|
||||
"new_email_code_required": "请输入验证码"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"page_title": "每日签到",
|
||||
"sign_rules": "签到规则",
|
||||
"sign_button": "签到",
|
||||
"signed": "已签到",
|
||||
"consecutive_days": "连续签到",
|
||||
"days": "天",
|
||||
"tomorrow_points": "明天继续签到可获得 {points} 积分~",
|
||||
"sign_success": "签到成功!获得{points}积分",
|
||||
"sign_failed": "签到失败",
|
||||
"get_sign_status_failed": "获取签到状态失败",
|
||||
"get_sign_calendar_failed": "获取签到日历失败",
|
||||
"rules_content": "签到规则:\n1. 每日签到可获得1-5积分\n2. 连续签到天数越多,获得积分越多\n3. 连续签到7天可获得额外奖励"
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
<template>
|
||||
<el-container class="layout-container">
|
||||
<el-header class="layout-header">
|
||||
<layout-header />
|
||||
</el-header>
|
||||
<el-scrollbar :style="calcHeight(60)" class="main-scrollbar" ref="mainScrollbarRef">
|
||||
<div class="layout-main">
|
||||
<div class="layout-content">
|
||||
<slot></slot>
|
||||
</div>
|
||||
</div>
|
||||
</el-scrollbar>
|
||||
</el-container>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { useTemplateRef, watch, provide } from 'vue';
|
||||
import layoutHeader from "./components/header.vue";
|
||||
|
||||
const route = useRoute();
|
||||
const mainScrollbarRef = useTemplateRef('mainScrollbarRef');
|
||||
|
||||
// 计算滚动区域高度
|
||||
const calcHeight = (headerHeight: number) => {
|
||||
return {
|
||||
height: `calc(100vh - ${headerHeight}px)`,
|
||||
maxHeight: `calc(100vh - ${headerHeight}px)`
|
||||
};
|
||||
};
|
||||
|
||||
// 路由切换时滚动条滚动至顶部
|
||||
watch(
|
||||
() => route.fullPath,
|
||||
() => {
|
||||
if (!route.meta.disableScrollTo) {
|
||||
mainScrollbarRef.value?.scrollTo(0, 0);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// 将滚动条的 ref provide 给子级组件
|
||||
provide('mainScrollbarRef', mainScrollbarRef);
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.layout-container {
|
||||
width: 100%;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.layout-header {
|
||||
position: relative;
|
||||
z-index: 1000;
|
||||
background-color: var(--ma-bg-color-overlay);
|
||||
height: 60px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.main-scrollbar {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
|
||||
.layout-main {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 100%;
|
||||
|
||||
.layout-content {
|
||||
flex: 1;
|
||||
min-height: var(--content-min-height, 500px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
overflow-x: hidden;
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* 全局重置 */
|
||||
:global(html, body) {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
width: 100%;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
/* 响应式断点 */
|
||||
@media screen and (max-width: 768px) {
|
||||
.layout-container {
|
||||
.layout-main {
|
||||
.layout-content {
|
||||
--content-min-height: 300px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 1024px) {
|
||||
.layout-container {
|
||||
.layout-main {
|
||||
.layout-content {
|
||||
--content-min-height: 400px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* 组件样式适配 */
|
||||
.layout-header {
|
||||
position: fixed;
|
||||
width: 100%;
|
||||
z-index: 1000;
|
||||
left: 0;
|
||||
right: 0;
|
||||
|
||||
:deep(.header-logo) {
|
||||
span {
|
||||
padding-left: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.frontend-header-menu) {
|
||||
background: transparent;
|
||||
.el-menu-item,
|
||||
.el-sub-menu .el-sub-menu__title {
|
||||
&.is-active {
|
||||
color: var(--el-color-primary) !important;
|
||||
}
|
||||
&:hover {
|
||||
background-color: transparent !important;
|
||||
color: var(--el-menu-hover-text-color) !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* 页面特定样式 */
|
||||
.page-index {
|
||||
.layout-content {
|
||||
/* 首页特定样式 */
|
||||
}
|
||||
}
|
||||
|
||||
.page-member {
|
||||
.layout-content {
|
||||
/* 会员页面特定样式 */
|
||||
}
|
||||
}
|
||||
|
||||
/* 滚动条样式优化 */
|
||||
.main-scrollbar {
|
||||
:deep(.el-scrollbar__thumb) {
|
||||
background-color: rgba(0, 0, 0, 0.2);
|
||||
border-radius: 4px;
|
||||
&:hover {
|
||||
background-color: rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.el-scrollbar__track) {
|
||||
background-color: rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
}
|
||||
|
||||
/* 深色模式滚动条 */
|
||||
@at-root html.dark {
|
||||
.main-scrollbar {
|
||||
:deep(.el-scrollbar__thumb) {
|
||||
background-color: rgba(255, 255, 255, 0.2);
|
||||
&:hover {
|
||||
background-color: rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.el-scrollbar__track) {
|
||||
background-color: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
}
|
||||
|
||||
.page-index {
|
||||
background: url(~/assets/images/bg-dark.jpg) repeat;
|
||||
}
|
||||
}
|
||||
|
||||
/* 响应式适配 */
|
||||
@media screen and (max-width: 1024px) {
|
||||
.layout-container {
|
||||
.main-scrollbar {
|
||||
.layout-main {
|
||||
.layout-content {
|
||||
padding: 0 15px;
|
||||
--content-min-height: 400px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 768px) {
|
||||
.layout-container {
|
||||
.main-scrollbar {
|
||||
.layout-main {
|
||||
.layout-content {
|
||||
padding: 0 10px;
|
||||
--content-min-height: 300px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-height: 650px) {
|
||||
.layout-container {
|
||||
.main-scrollbar {
|
||||
.layout-main {
|
||||
.layout-content {
|
||||
--content-min-height: 200px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,248 @@
|
||||
<template>
|
||||
<el-aside class="member-sidebar">
|
||||
<!-- 用户信息区域 -->
|
||||
<div class="member-sidebar__user-info">
|
||||
<div @click="routerPush('/member/profile')" class="member-sidebar__avatar-container">
|
||||
<img class="member-sidebar__avatar" :src="getAvatarUrl(memberStore?.info?.avatar)" alt="" />
|
||||
</div>
|
||||
<p class="member-sidebar__username">{{ memberStore?.info?.nickname }}</p>
|
||||
</div>
|
||||
|
||||
<el-menu
|
||||
class="member-sidebar__menu"
|
||||
:default-active="activeMenu"
|
||||
unique-opened
|
||||
>
|
||||
<div v-for="(item, idx) in systemStore.site.member_menu" :key="idx">
|
||||
<el-sub-menu
|
||||
v-if="item.children && item.children.length && checkMenuShow(item)"
|
||||
:index="item.id.toString()"
|
||||
>
|
||||
<template #title>
|
||||
<Icon v-if="item.icon" :icon="item.icon" size="16" />
|
||||
<span>{{ item.title }}</span>
|
||||
</template>
|
||||
<div v-for="(menu, index) in item.children.filter(m => m)" :key="index">
|
||||
<el-menu-item
|
||||
v-if="checkMenuShow(menu)"
|
||||
:index="menu.path"
|
||||
@click="routerPush(menu)"
|
||||
>
|
||||
<Icon v-if="menu.icon" :icon="menu.icon" size="16" />
|
||||
<span>{{ menu.title }}</span>
|
||||
</el-menu-item>
|
||||
</div>
|
||||
</el-sub-menu>
|
||||
|
||||
<el-menu-item
|
||||
v-else-if="checkMenuShow(item)"
|
||||
:index="item.path"
|
||||
@click="routerPush(item)"
|
||||
>
|
||||
<Icon v-if="item.icon" :icon="item.icon" size="16" />
|
||||
<span>{{ item.title }}</span>
|
||||
</el-menu-item>
|
||||
</div>
|
||||
</el-menu>
|
||||
</el-aside>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Menus } from '~/stores/interface'
|
||||
// 在组件顶部导入图片
|
||||
import defaultAvatar from '~/assets/images/default_avatar.png'
|
||||
import { useSystemStore } from '~/stores/system'
|
||||
import { useMemberStore } from '~/stores/member'
|
||||
import { useRoute, navigateTo } from 'nuxt/app'
|
||||
import { computed } from 'vue'
|
||||
import { fullUrl } from '~/utils/common'
|
||||
import { Icon } from '~/components/icon'
|
||||
|
||||
const route = useRoute()
|
||||
const memberStore = useMemberStore()
|
||||
const systemStore = useSystemStore()
|
||||
|
||||
const checkMenuShow = (menu: Menus): boolean => {
|
||||
if (!menu) {
|
||||
return false
|
||||
}
|
||||
|
||||
const permissions = menu.meta?.permissions
|
||||
|
||||
if (!permissions || permissions.length === 0) {
|
||||
return true
|
||||
}
|
||||
|
||||
const result = systemStore.checkMenuPermission(menu)
|
||||
return result
|
||||
}
|
||||
|
||||
// 当前激活的菜单
|
||||
const activeMenu = computed(() => {
|
||||
return route.path
|
||||
})
|
||||
|
||||
/**
|
||||
* 获取用户头像URL
|
||||
*/
|
||||
const getAvatarUrl = (avatarUrl: string | null | undefined): string => {
|
||||
if (!avatarUrl || avatarUrl.trim() === "") {
|
||||
return defaultAvatar
|
||||
}
|
||||
return fullUrl(avatarUrl)
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理菜单点击
|
||||
*/
|
||||
const onClickMenu = (menu: Menus) => {
|
||||
if (systemStore.isDirectory(menu)) {
|
||||
return
|
||||
}
|
||||
|
||||
if (systemStore.isExternalLink(menu)) {
|
||||
const target = systemStore.getTarget(menu)
|
||||
if (menu.url) {
|
||||
window.open(menu.url, target)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (menu.path) {
|
||||
navigateTo(menu.path)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 菜单跳转
|
||||
*/
|
||||
const routerPush = (route: string | Menus) => {
|
||||
if (typeof route === 'string') {
|
||||
navigateTo(route)
|
||||
} else {
|
||||
onClickMenu(route)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.member-sidebar {
|
||||
width: 240px;
|
||||
background-color: var(--ma-bg-color-overlay);
|
||||
box-shadow: var(--el-box-shadow-light);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.member-sidebar__user-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 24px 20px;
|
||||
border-bottom: 1px solid var(--el-border-color-light);
|
||||
}
|
||||
|
||||
.member-sidebar__avatar-container {
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.member-sidebar__avatar {
|
||||
display: block;
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
background-color: black;
|
||||
}
|
||||
|
||||
.member-sidebar__avatar-gender {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
height: 20px;
|
||||
width: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: var(--ma-bg-color-overlay);
|
||||
border-radius: 50%;
|
||||
box-shadow: var(--el-box-shadow);
|
||||
}
|
||||
|
||||
.member-sidebar__username {
|
||||
text-align: center;
|
||||
width: 100%;
|
||||
margin: 8px 0 16px;
|
||||
font-size: var(--el-font-size-large);
|
||||
font-weight: 600;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
|
||||
.member-sidebar__button-group {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.member-sidebar__button {
|
||||
font-size: var(--el-font-size-small);
|
||||
height: 32px;
|
||||
min-width: 80px;
|
||||
}
|
||||
|
||||
.member-sidebar__menu {
|
||||
border: none;
|
||||
background: transparent;
|
||||
|
||||
:deep(.el-menu-item),
|
||||
:deep(.el-sub-menu__title) {
|
||||
height: 44px;
|
||||
line-height: 44px;
|
||||
|
||||
.icon {
|
||||
margin-right: 8px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.el-menu-item.is-active) {
|
||||
background-color: var(--el-color-primary-light-9);
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
/* 响应式设计 */
|
||||
@media screen and (max-width: 991px) {
|
||||
.member-sidebar {
|
||||
width: 100%;
|
||||
border-radius: 0;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.member-sidebar__user-info {
|
||||
padding: 20px 16px;
|
||||
}
|
||||
|
||||
.member-sidebar__avatar {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
}
|
||||
|
||||
.member-sidebar__button-group {
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.member-sidebar__button {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 768px) {
|
||||
.member-sidebar {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,89 @@
|
||||
<template>
|
||||
<div class="theme-toggle-content" @click="toggleDark">
|
||||
<div class="switch">
|
||||
<div class="switch-action">
|
||||
<Icon icon="ant-design:moon-outlined" color="#ffffff" size="13px" class="switch-icon dark-icon" />
|
||||
<Icon icon="ant-design:sun-outlined" color="#303133" size="13px" class="switch-icon light-icon" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { Icon } from '~/components/icon'
|
||||
import { getDark, setDark } from '~/utils/dark'
|
||||
|
||||
const isDark = computed(() => getDark())
|
||||
|
||||
function toggleDark() {
|
||||
setDark(!isDark.value)
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.theme-toggle-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 24px;
|
||||
padding: 0 12px;
|
||||
}
|
||||
.switch {
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
width: 40px;
|
||||
height: 20px;
|
||||
border: 1px solid var(--el-border-color);
|
||||
border-radius: 10px;
|
||||
box-sizing: border-box;
|
||||
background-color: var(--ma-bg-color);
|
||||
cursor: pointer;
|
||||
transition:
|
||||
border-color 0.3s,
|
||||
background-color 0.5s;
|
||||
}
|
||||
.switch-action {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: absolute;
|
||||
top: 1px;
|
||||
left: 1px;
|
||||
border-radius: 50%;
|
||||
background-color: #ffffff;
|
||||
transform: translate(0);
|
||||
color: var(--el-text-color-primary);
|
||||
transition: all 0.3s;
|
||||
}
|
||||
.switch-icon {
|
||||
position: absolute;
|
||||
left: 1px;
|
||||
transition: all 0.3s;
|
||||
cursor: pointer;
|
||||
}
|
||||
.light-icon {
|
||||
opacity: 1;
|
||||
}
|
||||
.dark-icon {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
@at-root html.dark {
|
||||
.switch {
|
||||
background-color: #2c2c2c;
|
||||
}
|
||||
.switch-action {
|
||||
transform: translate(20px);
|
||||
background-color: #141414;
|
||||
}
|
||||
.dark-icon {
|
||||
opacity: 1;
|
||||
}
|
||||
.light-icon {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,16 @@
|
||||
<template>
|
||||
<button class="inline-flex justify-center items-center" @click="toggleDark">
|
||||
<div class="i-ri-sun-line dark:i-ri-moon-line" />
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { getDark, setDark } from '~/utils/dark'
|
||||
|
||||
const isDark = computed(() => getDark())
|
||||
|
||||
function toggleDark() {
|
||||
setDark(!isDark.value)
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,192 @@
|
||||
<template>
|
||||
<el-footer class="md-footer">
|
||||
<div class="footer-content">
|
||||
<!-- 友情链接区域 -->
|
||||
<div v-if="links.length > 0" class="footer-links-section">
|
||||
<h3 class="footer-links-title">友情链接</h3>
|
||||
<div class="footer-links-container">
|
||||
<a
|
||||
v-for="link in links"
|
||||
:key="link.id"
|
||||
:href="link.url"
|
||||
:target="link.target || '_blank'"
|
||||
:title="link.name"
|
||||
class="footer-link"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<img
|
||||
v-if="link.logo"
|
||||
:src="link.logo"
|
||||
:alt="link.name"
|
||||
class="footer-link-logo"
|
||||
/>
|
||||
<span v-else class="footer-link-text">{{ link.name }}</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 版权区域 -->
|
||||
<div class="footer-copyright">
|
||||
<div class="copyright-text">{{ configStore.siteSetting?.copyright }}</div>
|
||||
<div
|
||||
v-if="configStore.siteSetting?.icp || configStore.siteSetting?.network_security"
|
||||
class="copyright-links"
|
||||
>
|
||||
<a
|
||||
v-if="configStore.siteSetting?.icp"
|
||||
:href="configStore.siteSetting?.icp_url || 'https://beian.miit.gov.cn/'"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="copyright-link"
|
||||
>
|
||||
{{ configStore.siteSetting?.icp }}
|
||||
</a>
|
||||
<a
|
||||
v-if="configStore.siteSetting?.network_security"
|
||||
:href="configStore.siteSetting?.network_security_url"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="copyright-link"
|
||||
>
|
||||
{{ configStore.siteSetting?.network_security }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-footer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useConfigStore } from '~/stores/config'
|
||||
import { getLinks } from '~/api/site'
|
||||
|
||||
const configStore = useConfigStore()
|
||||
const links = ref([])
|
||||
|
||||
// 初始化加载友情链接
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const response = await getLinks({ type: 'footer' })
|
||||
links.value = response || []
|
||||
} catch (error) {
|
||||
console.error('获取页脚链接失败:', error)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
export default {
|
||||
name: 'LayoutFooter'
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.md-footer {
|
||||
padding: 40px 0;
|
||||
}
|
||||
|
||||
.footer-content {
|
||||
max-width: 1450px;
|
||||
width: 100%;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
// 友情链接区域样式
|
||||
.footer-links-section {
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.08);
|
||||
padding-bottom: 20px;
|
||||
margin-bottom: 30px;
|
||||
|
||||
.footer-links-title {
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
color: var(--el-text-color-primary);
|
||||
margin-bottom: 15px;
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.footer-links-container {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.footer-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 5px 15px;
|
||||
color: var(--el-text-color-secondary);
|
||||
text-decoration: none;
|
||||
font-size: 14px;
|
||||
transition: color 0.3s ease;
|
||||
|
||||
&:hover {
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.footer-link-logo {
|
||||
max-width: 120px;
|
||||
max-height: 120px;
|
||||
object-fit: contain;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.footer-link-text {
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
|
||||
// 版权区域样式
|
||||
.footer-copyright {
|
||||
text-align: left;
|
||||
|
||||
.copyright-text {
|
||||
color: var(--el-text-color-secondary);
|
||||
font-size: 13px;
|
||||
line-height: 1.8;
|
||||
padding-bottom: 15px;
|
||||
}
|
||||
|
||||
.copyright-links {
|
||||
display: flex;
|
||||
gap: 15px;
|
||||
flex-wrap: wrap;
|
||||
|
||||
.copyright-link {
|
||||
color: var(--el-text-color-secondary);
|
||||
text-decoration: none;
|
||||
font-size: 12px;
|
||||
transition: color 0.3s ease;
|
||||
|
||||
&:hover {
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 响应式设计
|
||||
@media screen and (max-width: 768px) {
|
||||
.footer-links-section {
|
||||
margin-bottom: 15px;
|
||||
padding-bottom: 15px;
|
||||
}
|
||||
|
||||
.footer-links-container {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.footer-link {
|
||||
font-size: 13px;
|
||||
padding: 4px 10px;
|
||||
}
|
||||
|
||||
.copyright-links {
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,334 @@
|
||||
<template>
|
||||
<el-menu :default-active="state.activeMenu" @select="onSelect">
|
||||
<!-- 语言切换 -->
|
||||
<el-sub-menu
|
||||
v-if="configStore.language.is_enabled && availableLanguages.length > 1"
|
||||
index="language-switch"
|
||||
class="language-switch"
|
||||
:key="languageKey"
|
||||
>
|
||||
<template #title>
|
||||
<Icon v-if="showIcon" icon="ant-design:translation-outlined" color="var(--el-text-color-primary)" />
|
||||
{{ t("common.language") }}
|
||||
</template>
|
||||
|
||||
<el-menu-item
|
||||
@click="handleLanguageChange(item.name)"
|
||||
v-for="item in availableLanguages"
|
||||
:key="item.name"
|
||||
:index="'language-switch-' + item.name"
|
||||
class="language-switch"
|
||||
:class="{ 'is-active': item.name === currentLanguage }"
|
||||
:title="`item.name: ${item.name}, currentLanguage: ${currentLanguage}`"
|
||||
>
|
||||
{{ item.value }}
|
||||
</el-menu-item>
|
||||
</el-sub-menu>
|
||||
|
||||
<!-- 主题切换 -->
|
||||
<el-menu-item
|
||||
v-if="configStore.theme.is_enabled"
|
||||
index="theme-switch"
|
||||
class="theme-switch"
|
||||
:class="$attrs.mode + '-theme-switch'"
|
||||
>
|
||||
<DarkSwitch />
|
||||
<!-- <DarkToggle v-if="!isMobile" /> -->
|
||||
</el-menu-item>
|
||||
|
||||
<!-- 已登录用户场景 -->
|
||||
<el-sub-menu
|
||||
v-if="memberStore.info"
|
||||
index="user-box"
|
||||
>
|
||||
<template #title>
|
||||
<div class="header-user-box">
|
||||
<img
|
||||
class="header-user-avatar"
|
||||
:class="$attrs.mode == 'vertical' ? 'icon-header-user-avatar' : ''"
|
||||
:src="getAvatarUrl(memberStore?.info?.avatar || '')"
|
||||
alt=""
|
||||
/>
|
||||
{{ memberStore?.info?.nickname || t("common.user") }}
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<el-menu-item index="user" @click="$emit('menu-click'); navigateTo('/member/profile')">
|
||||
<Icon
|
||||
v-if="showIcon"
|
||||
icon="ant-design:user-outlined"
|
||||
color="var(--el-text-color-primary)"
|
||||
/>
|
||||
{{ t("common.member_center") }}
|
||||
</el-menu-item>
|
||||
|
||||
<!-- 会员中心菜单 -->
|
||||
<MenuSub :menus="systemStore.site.member_menu" :show-icon="showIcon" @menu-click="$emit('menu-click')" />
|
||||
|
||||
<el-menu-item @click="$emit('menu-click'); memberStore.logout()" index="user-logout">
|
||||
<Icon
|
||||
v-if="showIcon"
|
||||
icon="ant-design:logout-outlined"
|
||||
color="var(--el-text-color-primary)"
|
||||
/>
|
||||
{{ t("common.logout_login") }}
|
||||
</el-menu-item>
|
||||
</el-sub-menu>
|
||||
|
||||
<!-- 未登录场景 -->
|
||||
<el-menu-item v-else @click="handleLoginClick" index="user">
|
||||
<Icon
|
||||
v-if="showIcon"
|
||||
icon="ant-design:user-outlined"
|
||||
color="var(--el-text-color-primary)"
|
||||
/>
|
||||
{{ t("common.login") }}
|
||||
</el-menu-item>
|
||||
<LoadingDialog />
|
||||
|
||||
</el-menu>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { computed, reactive, ref, nextTick, onMounted, onUnmounted, watch } from "vue";
|
||||
import { navigateTo } from "nuxt/app";
|
||||
import type { Menus } from "~/stores/interface";
|
||||
import defaultAvatar from "~/assets/images/default_avatar.png";
|
||||
import { fullUrl, getToken } from "~/utils/common";
|
||||
import DarkToggle from "./dark-toggle.vue";
|
||||
import DarkSwitch from "./dark-switch.vue";
|
||||
import LoadingDialog from "@/components/login-dialog/index.vue";
|
||||
import MenuSub from "./menu-sub.vue";
|
||||
import { Icon } from '~/components/icon';
|
||||
import { ElMessage } from "element-plus";
|
||||
import { useMemberStore } from "~/stores/member";
|
||||
import { useSystemStore } from "~/stores/system";
|
||||
import { useConfigStore } from "~/stores/config";
|
||||
import { t } from "~/composables/lang";
|
||||
import { getDark, setDark } from "~/utils/dark";
|
||||
|
||||
const memberStore = useMemberStore();
|
||||
const systemStore = useSystemStore();
|
||||
const configStore = useConfigStore();
|
||||
|
||||
// 添加响应式的语言 key,用于强制组件重新渲染
|
||||
const languageKey = ref(systemStore.lang);
|
||||
|
||||
// 监听语言变化,更新 languageKey
|
||||
watch(() => systemStore.lang, (newLang, oldLang) => {
|
||||
if (newLang !== oldLang) {
|
||||
languageKey.value = newLang;
|
||||
}
|
||||
}, { immediate: true });
|
||||
|
||||
const availableLanguages = computed(() => {
|
||||
const languages = systemStore.getLanguages;
|
||||
return languages;
|
||||
});
|
||||
const currentLanguage = computed(() => {
|
||||
// 从 systemStore 中获取语言设置,确保与状态保持同步
|
||||
return systemStore.lang;
|
||||
});
|
||||
|
||||
|
||||
|
||||
interface Props {
|
||||
showIcon?: boolean;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
showIcon: true,
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
'menu-click': []
|
||||
}>();
|
||||
|
||||
const state = reactive({
|
||||
activeMenu: "",
|
||||
});
|
||||
// 判断是否为移动端
|
||||
const isMobile = ref(false);
|
||||
|
||||
const checkMobile = () => {
|
||||
isMobile.value = window.innerWidth <= 768;
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取用户头像URL
|
||||
* @param avatarUrl 用户头像URL,可为空、null或undefined
|
||||
* @returns 完整的头像URL,如果传入空值则返回默认头像
|
||||
*/
|
||||
const getAvatarUrl = (avatarUrl: string | null | undefined): string => {
|
||||
if (!avatarUrl || avatarUrl.trim() === "") {
|
||||
// 返回默认头像
|
||||
return defaultAvatar;
|
||||
}
|
||||
|
||||
return fullUrl(avatarUrl);
|
||||
};
|
||||
|
||||
const handleLogin = () => {
|
||||
if (
|
||||
!getToken() &&
|
||||
!configStore.login.is_username &&
|
||||
!configStore.login.is_mobile &&
|
||||
!configStore.login.is_bind_mobile
|
||||
) {
|
||||
ElMessage.error("商家未开启普通账号登录注册");
|
||||
return false;
|
||||
}
|
||||
memberStore.logOpen();
|
||||
};
|
||||
|
||||
const handleLoginClick = () => {
|
||||
handleLogin();
|
||||
emit('menu-click');
|
||||
};
|
||||
|
||||
/**
|
||||
* 处理语言切换
|
||||
* @param lang 语言代码
|
||||
*/
|
||||
const handleLanguageChange = (lang: string) => {
|
||||
const success = systemStore.setLanguage(lang);
|
||||
|
||||
// 强制更新 languageKey,触发组件重新渲染
|
||||
languageKey.value = lang;
|
||||
|
||||
// 不再刷新页面,语言切换应该立即生效
|
||||
};
|
||||
|
||||
/**
|
||||
* 菜单被点击时额外对无需激活的菜单处理(外链、暗黑模式开关、语言切换等)
|
||||
* 检查菜单是否需要激活,如果否,还原 state.activeMenu
|
||||
*/
|
||||
const onSelect = (index: string) => {
|
||||
if (noNeedActive(systemStore.site.head_nav, index)) {
|
||||
const oldActiveMenu = state.activeMenu;
|
||||
state.activeMenu = "";
|
||||
nextTick(() => {
|
||||
state.activeMenu = oldActiveMenu;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 检查一个菜单是否需要激活态
|
||||
* @param menus
|
||||
* @param index
|
||||
*/
|
||||
const noNeedActive = (menus: Menus[], index: string) => {
|
||||
if (index.indexOf("language-switch") === 0 || index == "theme-switch") {
|
||||
return true;
|
||||
}
|
||||
return isExternalLink(menus, index);
|
||||
};
|
||||
|
||||
/**
|
||||
* 检查一个菜单是否是外站链接,如果是,不要激活
|
||||
* @param menus
|
||||
* @param index
|
||||
*/
|
||||
const isExternalLink = (menus: Menus[], index: string): boolean => {
|
||||
// 修复:添加空值检查
|
||||
if (!menus || !Array.isArray(menus)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
for (const key in menus) {
|
||||
// 修复:检查 menus[key] 是否存在
|
||||
if (!menus[key]) continue;
|
||||
|
||||
const columnIndex = `column-${menus[key].id}`;
|
||||
if (columnIndex == index) {
|
||||
return menus[key].meta?.menu_type == "link";
|
||||
}
|
||||
// 修复:添加 children 存在性检查
|
||||
if (
|
||||
menus[key].children &&
|
||||
Array.isArray(menus[key].children) &&
|
||||
menus[key].children.length
|
||||
) {
|
||||
const result = isExternalLink(menus[key].children, index);
|
||||
if (result) return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
};
|
||||
const toggleDarkMode = () => {
|
||||
setDark(!getDark());
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
checkMobile();
|
||||
window.addEventListener("resize", checkMobile);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener("resize", checkMobile);
|
||||
});
|
||||
</script>
|
||||
<style scoped lang="scss">
|
||||
.header-user-box {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
.header-user-avatar {
|
||||
width: 25px;
|
||||
height: 25px;
|
||||
margin-right: 4px;
|
||||
border-radius: 50%;
|
||||
background-color: black;
|
||||
}
|
||||
.icon-header-user-avatar {
|
||||
margin-left: 4px;
|
||||
margin-right: 6px;
|
||||
}
|
||||
}
|
||||
.el-sub-menu .icon,
|
||||
.el-menu-item .icon {
|
||||
vertical-align: middle;
|
||||
margin-right: 2px;
|
||||
width: 24px;
|
||||
text-align: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.is-active > .icon {
|
||||
color: var(--el-menu-active-color) !important;
|
||||
}
|
||||
.el-menu {
|
||||
border-bottom: none;
|
||||
border-right: none;
|
||||
.theme-switch.is-active,
|
||||
.language-switch.is-active {
|
||||
border-bottom: none;
|
||||
color: var(--el-menu-active-color) !important;
|
||||
font-weight: 500 !important;
|
||||
:deep(.el-sub-menu__title) {
|
||||
border-bottom: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
.theme-switch {
|
||||
--el-menu-hover-bg-color: none;
|
||||
padding-right: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.vertical-theme-switch {
|
||||
.theme-toggle-content {
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
.theme-toggle-content {
|
||||
padding-right: 0;
|
||||
}
|
||||
|
||||
// 语言切换选中状态
|
||||
.language-switch.is-active {
|
||||
color: var(--el-menu-active-color) !important;
|
||||
font-weight: 500;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,268 @@
|
||||
<template>
|
||||
<div class="ma-header" :class="{ 'is-scrolled': isScrolled }">
|
||||
<div class="header-container">
|
||||
<!-- 左侧logo -->
|
||||
<div class="header-left">
|
||||
<Logo clickable show-text size="medium" />
|
||||
</div>
|
||||
|
||||
<!-- 中间菜单区域(靠右显示) -->
|
||||
<div class="header-center">
|
||||
<client-only>
|
||||
<el-scrollbar class="hidden-sm-and-down" style="height: var(--el-header-height);">
|
||||
<Menu class="frontend-header-menu" :ellipsis="false" mode="horizontal" :show-icon="true" />
|
||||
</el-scrollbar>
|
||||
</client-only>
|
||||
</div>
|
||||
|
||||
<!-- 右侧功能区域 -->
|
||||
<div class="header-right">
|
||||
<client-only>
|
||||
<div v-if="isMobile" class="mobile-menu-wrapper">
|
||||
<div
|
||||
v-if="!systemStore.$state.site.menu_expand"
|
||||
@click="systemStore.toggleMenuExpand(true)"
|
||||
class="mobile-menu-toggle mr-2"
|
||||
>
|
||||
<Icon icon="ant-design:menu-outlined" color="var(--el-color-primary)" size="20" />
|
||||
</div>
|
||||
</div>
|
||||
<el-scrollbar class="hidden-sm-and-down" style="height: var(--el-header-height);">
|
||||
<HeaderActions class="frontend-header-menu" :ellipsis="false" mode="horizontal" v-if="!isMobile"/>
|
||||
</el-scrollbar>
|
||||
</client-only>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 移动端抽屉菜单(仅在移动端显示) -->
|
||||
<MobileDrawer
|
||||
v-if="isMobile"
|
||||
v-model="systemStore.$state.site.menu_expand"
|
||||
@language-changed="handleLanguageChange"
|
||||
@logged-out="handleLogout"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Menu from './menu.vue'
|
||||
import Logo from './logo.vue'
|
||||
import MobileDrawer from './mobile-drawer.vue'
|
||||
import HeaderActions from './header-actions.vue'
|
||||
import { Icon } from '~/components/icon'
|
||||
import { useSystemStore } from '~/stores/system'
|
||||
import { inject, ref, nextTick, onMounted, onUnmounted } from 'vue'
|
||||
|
||||
// 注入父级提供的 scrollbar ref
|
||||
const mainScrollbarRef = inject('mainScrollbarRef', ref(null))
|
||||
|
||||
|
||||
// 判断是否为移动端
|
||||
const isMobile = ref(false)
|
||||
const isScrolled = ref(false)
|
||||
const systemStore = useSystemStore()
|
||||
|
||||
const checkMobile = () => {
|
||||
isMobile.value = window.innerWidth <= 768
|
||||
}
|
||||
|
||||
const handleScroll = () => {
|
||||
const scrollbar = mainScrollbarRef.value as any
|
||||
if (scrollbar) {
|
||||
const wrap = scrollbar.wrapRef || scrollbar.$el?.querySelector?.('.el-scrollbar__wrap')
|
||||
if (wrap) {
|
||||
isScrolled.value = wrap.scrollTop > 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
checkMobile()
|
||||
nextTick(() => {
|
||||
handleScroll()
|
||||
const scrollbar = mainScrollbarRef.value as any
|
||||
const wrap = scrollbar?.wrapRef || scrollbar?.$el?.querySelector?.('.el-scrollbar__wrap')
|
||||
if (wrap) {
|
||||
wrap.addEventListener('scroll', handleScroll)
|
||||
}
|
||||
})
|
||||
window.addEventListener('resize', checkMobile)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('resize', checkMobile)
|
||||
const scrollbar = mainScrollbarRef.value as any
|
||||
const wrap = scrollbar?.wrapRef || scrollbar?.$el?.querySelector?.('.el-scrollbar__wrap')
|
||||
if (wrap) {
|
||||
wrap.removeEventListener('scroll', handleScroll)
|
||||
}
|
||||
})
|
||||
|
||||
const handleLanguageChange = (languageName: string) => {
|
||||
if (isMobile.value) {
|
||||
useSystemStore().toggleMenuExpand(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleLogout = () => {
|
||||
// 可以添加额外的登出处理逻辑
|
||||
}
|
||||
|
||||
const toggleDarkMode = () => {
|
||||
setDark(!getDark())
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.ma-header {
|
||||
background-color: var(--ma-bg-color-overlay);
|
||||
position: relative;
|
||||
z-index: 1000;
|
||||
width: 100%;
|
||||
|
||||
// 使用伪元素实现底部边框线,横跨整个视口宽度
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 100vw;
|
||||
height: 1px;
|
||||
background-color: transparent;
|
||||
transition: background-color 0.3s ease;
|
||||
}
|
||||
|
||||
&.is-scrolled::after {
|
||||
background-color: var(--el-border-color-light);
|
||||
}
|
||||
}
|
||||
|
||||
.header-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
max-width: 1450px;
|
||||
margin: 0 auto;
|
||||
padding: 0 20px;
|
||||
height: var(--el-header-height);
|
||||
}
|
||||
|
||||
.header-left {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.header-center {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin: 0 20px;
|
||||
|
||||
.frontend-header-menu {
|
||||
height: var(--el-header-height);
|
||||
background: transparent;
|
||||
.el-menu-item,
|
||||
.el-sub-menu .el-sub-menu__title {
|
||||
&.is-active {
|
||||
color: var(--el-color-primary) !important;
|
||||
}
|
||||
&:hover {
|
||||
background-color: transparent !important;
|
||||
color: var(--el-menu-hover-text-color) !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.el-menu--horizontal) {
|
||||
border-bottom: none;
|
||||
}
|
||||
}
|
||||
|
||||
.header-right {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 0;
|
||||
|
||||
.frontend-header-menu {
|
||||
height: var(--el-header-height);
|
||||
background: transparent;
|
||||
.el-menu-item,
|
||||
.el-sub-menu .el-sub-menu__title {
|
||||
&.is-active {
|
||||
color: var(--el-color-primary) !important;
|
||||
}
|
||||
&:hover {
|
||||
background-color: transparent !important;
|
||||
color: var(--el-menu-hover-text-color) !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.header-action-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 8px 12px;
|
||||
cursor: pointer;
|
||||
border-radius: 6px;
|
||||
transition: all 0.3s ease;
|
||||
color: var(--el-text-color-primary);
|
||||
|
||||
&:hover {
|
||||
background-color: var(--el-fill-color-light);
|
||||
}
|
||||
}
|
||||
|
||||
.mobile-menu-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 8px;
|
||||
cursor: pointer;
|
||||
border-radius: 6px;
|
||||
height: 40px;
|
||||
width: 40px;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--el-fill-color-light);
|
||||
}
|
||||
}
|
||||
|
||||
.mobile-menu-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
// 响应式设计
|
||||
@media screen and (max-width: 768px) {
|
||||
.header-container {
|
||||
padding: 0 15px;
|
||||
}
|
||||
|
||||
.header-center {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.header-right {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 414px) {
|
||||
.header-container {
|
||||
padding: 0 10px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,105 @@
|
||||
<template>
|
||||
<div @click="handleClick" class="logo-container" :class="{ 'clickable': clickable, [`size-${size}`]: true }">
|
||||
<img :src="effectiveLogoUrl" :alt="altText" class="logo-image" />
|
||||
<span v-if="showText" class="site-name">{{ siteName }} </span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
interface Props {
|
||||
clickable?: boolean
|
||||
showText?: boolean
|
||||
size?: 'small' | 'medium' | 'large'
|
||||
logoUrl?: string
|
||||
altText?: string
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
clickable: true,
|
||||
showText: true,
|
||||
size: 'medium',
|
||||
logoUrl: '',
|
||||
altText: 'Logo'
|
||||
})
|
||||
|
||||
const systemStore = useSystemStore()
|
||||
const siteName = computed(() => systemStore?.site?.site_name || '快速开发框架')
|
||||
|
||||
// 在组件顶部导入图片
|
||||
import logoImage from '~/assets/images/logo.png'
|
||||
|
||||
const effectiveLogoUrl = computed(() => {
|
||||
if (props.logoUrl) {
|
||||
return props.logoUrl
|
||||
}
|
||||
return logoImage
|
||||
})
|
||||
|
||||
const handleClick = () => {
|
||||
if (props.clickable) {
|
||||
navigateTo('/')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.logo-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
cursor: default;
|
||||
|
||||
&.clickable {
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
|
||||
// 修复:尺寸样式应该设置在父容器上,然后影响子元素
|
||||
&.size-small {
|
||||
.logo-image {
|
||||
height: 24px;
|
||||
width: 24px;
|
||||
}
|
||||
}
|
||||
|
||||
&.size-medium {
|
||||
.logo-image {
|
||||
height: 34px;
|
||||
width: 34px;
|
||||
}
|
||||
}
|
||||
|
||||
&.size-large {
|
||||
.logo-image {
|
||||
height: 48px;
|
||||
width: 48px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.logo-image {
|
||||
// 基础样式可以放在这里
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.site-name {
|
||||
padding-left: 8px;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
color: var(--el-text-color-primary);
|
||||
|
||||
.logo-container.size-small & {
|
||||
font-size: var(--el-font-size-small);
|
||||
}
|
||||
|
||||
.logo-container.size-medium & {
|
||||
font-size: var(--el-font-size-large);
|
||||
}
|
||||
|
||||
.logo-container.size-large & {
|
||||
font-size: var(--el-font-size-extra-large);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,256 @@
|
||||
<template>
|
||||
<div v-for="(item, idx) in props.menus" :key="idx">
|
||||
<el-sub-menu v-if="systemStore.hasChildren(item) && checkMenuShow(item)" :index="`column-${item.id}`">
|
||||
<template #title>
|
||||
<Icon v-if="showIcon && item.icon" :icon="item.icon" color="var(--el-text-color-primary)" />
|
||||
{{ item.title }}
|
||||
</template>
|
||||
<MenuSub :menus="item.children" :show-icon="showIcon" @menu-click="$emit('menu-click')" />
|
||||
</el-sub-menu>
|
||||
<el-menu-item
|
||||
v-else-if="checkMenuShow(item)"
|
||||
@click="onClickMenu(item)"
|
||||
:index="'column-' + item.id"
|
||||
:class="getMenuItemClass(item)"
|
||||
:data-menu-id="item.id"
|
||||
:data-menu-name="item.name"
|
||||
:data-menu-path="item.path"
|
||||
:data-menu-controllable="item.extra?.controllable ? 'true' : undefined"
|
||||
>
|
||||
<Icon v-if="showIcon && item.icon" :icon="item.icon" color="var(--el-text-color-primary)" />
|
||||
<template #title>
|
||||
<span class="menu-title">{{ item.title }}</span>
|
||||
<!-- 徽章 -->
|
||||
<span
|
||||
v-if="item.extra?.badge"
|
||||
class="menu-badge"
|
||||
:class="[`badge-${item.extra?.badgeType || 'danger'}`]"
|
||||
:data-badge="item.extra.badge"
|
||||
>
|
||||
{{ item.extra.badge }}
|
||||
</span>
|
||||
<!-- 红点(仅显示状态无数量时) -->
|
||||
<span
|
||||
v-else-if="item.extra?.badge === '' || item.extra?.dot"
|
||||
class="menu-dot"
|
||||
:class="[`dot-${item.extra?.badgeType || 'danger'}`]"
|
||||
></span>
|
||||
</template>
|
||||
</el-menu-item>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Menus } from '~/stores/interface'
|
||||
import { navigateTo } from 'nuxt/app'
|
||||
import { useSystemStore } from '~/stores/system'
|
||||
import { Icon } from '~/components/icon'
|
||||
import { onMounted, onUnmounted } from 'vue'
|
||||
|
||||
interface Props {
|
||||
menus: Menus[]
|
||||
showIcon?: boolean
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
menus: () => [],
|
||||
showIcon: false,
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
'menu-click': []
|
||||
}>()
|
||||
|
||||
const systemStore = useSystemStore()
|
||||
|
||||
// 组件挂载后,如果有可操控菜单,触发事件通知外部
|
||||
onMounted(() => {
|
||||
const hasControllable = props.menus.some(item => item.extra?.controllable)
|
||||
if (hasControllable) {
|
||||
window.dispatchEvent(new CustomEvent('menu:controllable:mounted'))
|
||||
}
|
||||
})
|
||||
|
||||
const checkMenuShow = (menu: Menus): boolean => {
|
||||
if (!menu) {
|
||||
return false
|
||||
}
|
||||
|
||||
const permissions = menu.meta?.permissions
|
||||
|
||||
if (!permissions || permissions.length === 0) {
|
||||
return true
|
||||
}
|
||||
|
||||
return systemStore.checkMenuPermission(menu)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取菜单项的 CSS 类名
|
||||
* - 提供可操作的 DOM 标识
|
||||
*/
|
||||
const getMenuItemClass = (item: Menus): string => {
|
||||
const classes: string[] = []
|
||||
|
||||
// 基础类:根据 name 生成(保留中文,仅替换特殊字符)
|
||||
if (item.name) {
|
||||
const baseClass = item.name.replace(/[^\w\u4e00-\u9fa5\-]/g, '-')
|
||||
if (baseClass) classes.push(`menu-${baseClass}`)
|
||||
}
|
||||
|
||||
// 可操控菜单标识(通过 extra.controllable)
|
||||
if (item.extra?.controllable) {
|
||||
classes.push('menu-controllable')
|
||||
}
|
||||
|
||||
// 有徽章时的特殊类
|
||||
if (item.extra?.badge || item.extra?.dot) {
|
||||
classes.push('has-badge')
|
||||
}
|
||||
|
||||
return classes.join(' ')
|
||||
}
|
||||
|
||||
const onClickMenu = (menu: Menus) => {
|
||||
// 检查权限
|
||||
if (!systemStore.checkMenuPermission(menu)) {
|
||||
return
|
||||
}
|
||||
|
||||
if (systemStore.isDirectory(menu)) {
|
||||
return
|
||||
}
|
||||
|
||||
// 先触发菜单点击事件,关闭弹窗
|
||||
emit('menu-click')
|
||||
|
||||
if (systemStore.isExternalLink(menu)) {
|
||||
const target = systemStore.getTarget(menu)
|
||||
if (menu.url) {
|
||||
window.open(menu.url, target)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (menu.path) {
|
||||
navigateTo(menu.path)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.el-sub-menu .icon,
|
||||
.el-menu-item .icon {
|
||||
vertical-align: middle;
|
||||
margin-right: 2px;
|
||||
width: 24px;
|
||||
text-align: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.is-active > .icon {
|
||||
color: var(--el-menu-active-color) !important;
|
||||
}
|
||||
|
||||
// 菜单标题
|
||||
.menu-title {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
// 徽章样式
|
||||
.menu-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 18px;
|
||||
height: 18px;
|
||||
padding: 0 6px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
border-radius: 9px;
|
||||
margin-left: 6px;
|
||||
flex-shrink: 0;
|
||||
|
||||
// 颜色变体
|
||||
&.badge-danger {
|
||||
background-color: var(--el-color-danger);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
&.badge-warning {
|
||||
background-color: var(--el-color-warning);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
&.badge-success {
|
||||
background-color: var(--el-color-success);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
&.badge-info {
|
||||
background-color: var(--el-color-info);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
&.badge-primary {
|
||||
background-color: var(--el-color-primary);
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
// 红点样式
|
||||
.menu-dot {
|
||||
display: inline-block;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
margin-left: 6px;
|
||||
flex-shrink: 0;
|
||||
|
||||
&.dot-danger {
|
||||
background-color: var(--el-color-danger);
|
||||
}
|
||||
|
||||
&.dot-warning {
|
||||
background-color: var(--el-color-warning);
|
||||
}
|
||||
|
||||
&.dot-success {
|
||||
background-color: var(--el-color-success);
|
||||
}
|
||||
|
||||
&.dot-info {
|
||||
background-color: var(--el-color-info);
|
||||
}
|
||||
|
||||
&.dot-primary {
|
||||
background-color: var(--el-color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
// 可操控菜单特殊样式(如通知菜单)
|
||||
:deep(.menu-controllable) {
|
||||
position: relative;
|
||||
|
||||
.menu-badge,
|
||||
.menu-dot {
|
||||
animation: pulse 2s infinite;
|
||||
}
|
||||
}
|
||||
|
||||
// 脉冲动画
|
||||
@keyframes pulse {
|
||||
0%, 100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
|
||||
// 有徽章的菜单项高亮
|
||||
:deep(.has-badge) {
|
||||
.menu-title {
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,185 @@
|
||||
<template>
|
||||
<el-menu :default-active="state.activeMenu" @select="onSelect">
|
||||
<MenuSub :menus="systemStore.navMenu" :show-icon="showIcon" @menu-click="$emit('menu-click')" :key="navMenuKey" />
|
||||
</el-menu>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { RouteLocationNormalizedLoaded } from 'vue-router'
|
||||
import type { Menus } from '~/stores/interface'
|
||||
import { reactive, nextTick, watch } from 'vue'
|
||||
import { useRoute, navigateTo } from 'nuxt/app'
|
||||
import { useSystemStore } from '~/stores/system'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import MenuSub from './menu-sub.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const systemStore = useSystemStore()
|
||||
const { t } = useI18n()
|
||||
|
||||
interface Props {
|
||||
showIcon?: boolean
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
showIcon: false,
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
'menu-click': []
|
||||
}>()
|
||||
|
||||
const state = reactive({
|
||||
activeMenu: '',
|
||||
switchingLanguage: false,
|
||||
})
|
||||
|
||||
// 调试用:检查 navMenu 中是否有消息中心
|
||||
const navMenuKey = computed(() => {
|
||||
const hasNotify = systemStore.navMenu?.some((m: Menus) => m.name === '消息中心' || m.path === '/notify')
|
||||
console.log('[Menu] navMenu has 消息中心:', hasNotify, 'menus:', systemStore.navMenu?.map((m: Menus) => m.name))
|
||||
return hasNotify ? 'with-notify' : 'no-notify'
|
||||
})
|
||||
|
||||
const setActiveMenu = (route: RouteLocationNormalizedLoaded) => {
|
||||
if (route.path == '/') return (state.activeMenu = 'index')
|
||||
|
||||
const menuId = findMenus(route)
|
||||
if (menuId) {
|
||||
state.activeMenu = 'column-' + menuId
|
||||
} else if (route.path.startsWith('/user')) {
|
||||
state.activeMenu = 'user'
|
||||
}
|
||||
}
|
||||
|
||||
const onSelect = (index: string) => {
|
||||
if (noNeedActive(systemStore.headNav, index)) {
|
||||
const oldActiveMenu = state.activeMenu
|
||||
state.activeMenu = ''
|
||||
nextTick(() => {
|
||||
state.activeMenu = oldActiveMenu
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const noNeedActive = (menus: Menus[], index: string) => {
|
||||
if (index.indexOf('language-switch') === 0 || index == 'theme-switch') {
|
||||
return true
|
||||
}
|
||||
return isExternalLink(menus, index)
|
||||
}
|
||||
|
||||
const isExternalLink = (menus: Menus[], index: string): boolean => {
|
||||
if (!menus || !Array.isArray(menus)) {
|
||||
return false
|
||||
}
|
||||
|
||||
for (const menu of menus) {
|
||||
if (!menu) continue
|
||||
|
||||
const columnIndex = `column-${menu.id}`
|
||||
if (columnIndex == index) {
|
||||
return systemStore.isExternalLink(menu)
|
||||
}
|
||||
if (menu.children && Array.isArray(menu.children) && menu.children.length) {
|
||||
const result = isExternalLink(menu.children, index)
|
||||
if (result) return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
const searchMenuIndex = (menus: Menus[], route: RouteLocationNormalizedLoaded): number | false => {
|
||||
if (!menus || !Array.isArray(menus)) {
|
||||
return false
|
||||
}
|
||||
|
||||
let find: boolean | number = false
|
||||
for (const menu of menus) {
|
||||
if (!menu) continue
|
||||
|
||||
if (menu.id && menu.path == route.fullPath) {
|
||||
return menu.id
|
||||
}
|
||||
if (menu.children && Array.isArray(menu.children) && menu.children.length) {
|
||||
find = searchMenuIndex(menu.children, route)
|
||||
if (find !== false) return find
|
||||
}
|
||||
}
|
||||
return find
|
||||
}
|
||||
|
||||
const handleClick = (path: string) => {
|
||||
navigateTo(path)
|
||||
}
|
||||
|
||||
const findMenus = (route: RouteLocationNormalizedLoaded) => {
|
||||
if (!systemStore.navMenu || !Array.isArray(systemStore.navMenu)) {
|
||||
return false
|
||||
}
|
||||
|
||||
const headNavIndex = searchMenuIndex(systemStore.navMenu, route)
|
||||
if (headNavIndex !== false) return headNavIndex
|
||||
}
|
||||
|
||||
setActiveMenu(route)
|
||||
watch(
|
||||
() => route.fullPath,
|
||||
() => {
|
||||
setActiveMenu(route)
|
||||
}
|
||||
)
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.header-user-box {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
.header-user-avatar {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
margin-right: 4px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
.icon-header-user-avatar {
|
||||
margin-left: 4px;
|
||||
margin-right: 6px;
|
||||
}
|
||||
}
|
||||
.el-sub-menu .icon,
|
||||
.el-menu-item .icon {
|
||||
vertical-align: middle;
|
||||
margin-right: 2px;
|
||||
width: 24px;
|
||||
text-align: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.is-active > .icon {
|
||||
color: var(--el-menu-active-color) !important;
|
||||
}
|
||||
.el-menu {
|
||||
border-bottom: none;
|
||||
border-right: none;
|
||||
.theme-switch.is-active,
|
||||
.language-switch.is-active {
|
||||
border-bottom: none;
|
||||
:deep(.el-sub-menu__title) {
|
||||
border-bottom: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
.theme-switch {
|
||||
--el-menu-hover-bg-color: none;
|
||||
padding-right: 0;
|
||||
}
|
||||
.vertical-theme-switch {
|
||||
.theme-toggle-content {
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
.theme-toggle-content {
|
||||
padding-right: 0;
|
||||
}
|
||||
</style>
|
||||