Compare commits

..

19 Commits

Author SHA1 Message Date
sugarBecomer ebf02eb58f 🐛 修改域名 2026-04-07 14:37:11 +08:00
sugarBecomer 5baa91e3e7 🐛 补充今日校园任务日志详情展示
为任务日志补充成功标题与失败原因字段,并在缺少 is_success 时根据 error_message 回退显示失败,便于定位执行结果。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-07 13:30:46 +08:00
sugarBecomer 08c47cfa03 Merge branch 'fix/cpdaily-task-log-display' 2026-04-07 13:26:30 +08:00
sugarBecomer cb5440c8d9 🐛 修复今日校园任务日志展示
修正任务日志字段映射,改为读取接口实际返回的 is_success 与 createTime,避免页面将执行结果和执行时间显示为 -。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-07 13:26:22 +08:00
sugarBecomer 05eaa4741a 🐛 混淆前端打包后代码 2026-03-27 22:00:19 +08:00
sugarBecomer c0d5b4697f 🐛 混淆前端打包后代码 2026-03-27 21:17:55 +08:00
sugarBecomer d742425cac ✨ 混淆前端打包后代码 2026-03-27 16:38:42 +08:00
sugarBecomer 5729e88687 ✨ 混淆前端打包后代码 2026-03-27 16:37:14 +08:00
sugarBecomer 0681aad27d 🐛 修复生成邀请码 2026-03-27 16:32:22 +08:00
sugarBecomer ed38bfd463 🐛 修复邮件问题 2026-03-27 16:12:34 +08:00
sugarBecomer 1c38eccfd3 🐛 请假问题 2026-03-27 00:38:15 +08:00
sugarBecomer ac6b0fa81a ✨ graph api发送邮件 2026-03-26 15:19:42 +08:00
sugarBecomer 2603aada26 ✨ smtp服务 2026-03-26 11:37:38 +08:00
sugarBecomer 223ddf4f66 ✨ 新增是否必填邀请码 2026-03-25 14:49:11 +08:00
sugarBecomer 1f58572b8f ✨ 请假功能 2026-03-25 13:47:11 +08:00
sugarBecomer a8125cc224 🐛 修复样式问题 2026-03-24 23:00:28 +08:00
sugarBecomer 1f519767af 🐛 修复样式问题 2026-03-24 22:48:06 +08:00
sugarBecomer 8e750311bd 🐛 调整两个版本邮件格式 2026-03-24 22:20:04 +08:00
sugarBecomer 4033153037 🐛 调整两个版本邮件格式 2026-03-24 22:13:01 +08:00
38 changed files with 3560 additions and 476 deletions
+1 -1
View File
@@ -6,7 +6,7 @@ on: [release]
env:
REPO: sugarbecomer/tianshen-web
REGISTRY: git.likedream.xyz
REGISTRY: mycoder.cc.cd
jobs:
Build-And-Upload:
+8
View File
@@ -19,9 +19,17 @@ http:// {
reverse_proxy ${BACKEND_URL}
}
@assets path /assets/* /favicon.svg
handle @assets {
root * /www
header Cache-Control "public, max-age=31536000, immutable"
file_server
}
handle {
root * /www
try_files {path} /index.html
header Cache-Control "no-store, no-cache, must-revalidate"
file_server
}
+1411
View File
File diff suppressed because it is too large Load Diff
+3
View File
@@ -19,6 +19,7 @@
"lucide-vue-next": "^1.0.0",
"md-editor-v3": "^6.4.1",
"pinia": "^3.0.4",
"qrcode": "^1.5.4",
"radix-vue": "^1.9.17",
"reka-ui": "^2.9.2",
"tailwind-merge": "^3.5.0",
@@ -31,12 +32,14 @@
"devDependencies": {
"@tailwindcss/vite": "^4.2.2",
"@types/node": "^24.12.0",
"@types/qrcode": "^1.5.6",
"@vitejs/plugin-vue": "^6.0.5",
"@vue/tsconfig": "^0.9.0",
"tailwindcss": "^4.2.2",
"tw-animate-css": "^1.4.0",
"typescript": "~5.9.3",
"vite": "^8.0.1",
"vite-plugin-bundle-obfuscator": "^1.11.0",
"vue-tsc": "^3.2.5"
}
}
+2
View File
@@ -1,10 +1,12 @@
<script setup lang="ts">
import { TooltipProvider } from '@/components/ui/tooltip'
import { Toaster } from '@/components/ui/sonner'
import LoadingBar from '@/components/common/LoadingBar.vue'
</script>
<template>
<TooltipProvider :delay-duration="200">
<LoadingBar />
<router-view />
<Toaster position="top-center" :duration="3000" rich-colors />
</TooltipProvider>
+15
View File
@@ -15,6 +15,7 @@ import type {
CpTaskExecuteParams,
CpTaskFormItem,
CpLeaveReportParams,
CpLeaveApplyParams,
TaskStatisticsItem,
TaskStatisticsV2Item,
DashboardSummary,
@@ -49,6 +50,10 @@ export const cpUserApi = {
request.post<any, ApiResponse>('/v1/cpdaily/user/authorizeConfirm', data),
customDevice: (data: CpUserCustomDeviceParams) =>
request.post<any, ApiResponse>('/v1/cpdaily/user/customDevice', data),
generateQr: () =>
request.post<any, ApiResponse<{ uuid: string }>>('/v1/cpdaily/user/generateQr'),
checkQr: (uuid: string) =>
request.get<any, ApiResponse<{ status: string; deviceCode?: string }>>('/v1/cpdaily/user/checkQr', { params: { uuid } }),
}
// ---- 任务管理 ----
@@ -113,4 +118,14 @@ export const cpLeaveApi = {
request.post<any, ApiResponse>('/v1/cpdaily/leave/report', data),
uploadImage: (formData: FormData) =>
request.post<any, ApiResponse>('/v1/cpdaily/leave/upload', formData),
checkLeave: (cpUserId: number) =>
request.get<any, ApiResponse>(`/v1/cpdaily/leave/check/${cpUserId}`),
getLeaveTypes: (cpUserId: number) =>
request.get<any, ApiResponse>(`/v1/cpdaily/leave/types/${cpUserId}`),
getLeaveTypeCfg: (data: { cpUserId: number; code: number }) =>
request.post<any, ApiResponse>('/v1/cpdaily/leave/typeCfg', data),
getApproveFlow: (data: { cpUserId: number; leaveType: number; startTime: string; endTime: string }) =>
request.post<any, ApiResponse>('/v1/cpdaily/leave/approveFlow', data),
apply: (data: CpLeaveApplyParams) =>
request.post<any, ApiResponse>('/v1/cpdaily/leave/apply', data),
}
+8 -2
View File
@@ -3,7 +3,7 @@ import { message } from '@/composables/useMessage'
const request = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL || '/api',
timeout: 15000,
timeout: 60000,
})
// ---- Token 自动刷新 ----
@@ -71,7 +71,9 @@ request.interceptors.response.use(
if (data && typeof data.code === 'number' && data.code !== 200) {
const msg = data.message || '请求失败'
message.error(msg)
return Promise.reject(new Error(msg))
const err: any = new Error(msg)
err.code = data.code
return Promise.reject(err)
}
return data
},
@@ -80,6 +82,10 @@ request.interceptors.response.use(
localStorage.removeItem('token')
const { default: router } = await import('@/router')
router.push('/login')
} else if (error.response?.data?.message) {
message.error(error.response.data.message)
} else if (error.message && !error.message.includes('canceled')) {
message.error(error.message)
}
return Promise.reject(error)
}
+45
View File
@@ -132,3 +132,48 @@ export const systemPriceApi = {
update: (data: PriceConfig) =>
request.put<any, ApiResponse>('/v1/system/config/price', data),
}
// ---- SMTP 配置 ----
export interface SmtpConfig {
authMode: 'smtp' | 'outlook_oauth2'
smtpHost: string
smtpPort: number
smtpUsername: string
smtpPassword: string
fromEmail: string
fromName: string
oauthTenant: string
oauthClientId: string
oauthClientSecret: string
oauthRedirectUrl: string
oauthRefreshToken: string
oauthScope: string
}
export interface OutlookOAuthAuthorizeURLReq {
oauthTenant: string
oauthClientId: string
oauthClientSecret: string
oauthScope: string
fromEmail: string
frontendOrigin: string
callbackBaseUrl: string
}
export interface OutlookOAuthAuthorizeURLResp {
authUrl: string
callbackUrl: string
state: string
}
export const systemSmtpApi = {
get: () =>
request.get<any, ApiResponse<SmtpConfig>>('/v1/system/config/smtp'),
update: (data: SmtpConfig) =>
request.put<any, ApiResponse>('/v1/system/config/smtp', data),
test: (toEmail: string) =>
request.post<any, ApiResponse>('/v1/system/config/smtp/test', { toEmail }),
getOutlookAuthorizeUrl: (data: OutlookOAuthAuthorizeURLReq) =>
request.post<any, ApiResponse<OutlookOAuthAuthorizeURLResp>>('/v1/system/config/smtp/oauth/outlook/authorize-url', data),
}
+4
View File
@@ -31,4 +31,8 @@ export const userApi = {
/** 获取余额变动记录 */
getBalanceRecords: (params: PageParams & { type?: string }) =>
request.get<any, ApiResponse<PageResponse<BalanceRecord>>>('/v1/user/balance/records', { params }),
/** 生成邀请码 */
generateInviteCode: () =>
request.post<any, ApiResponse>('/v1/user/generateInviteCode'),
}
+2 -2
View File
@@ -209,8 +209,8 @@ defineExpose({ refresh })
</div>
<!-- 分页 -->
<div v-if="total > 0" class="mt-4 flex flex-col sm:flex-row items-center justify-between gap-2">
<span class="text-sm text-muted-foreground">共 {{ total }} 条</span>
<div v-if="total > 0" class="mt-4 flex items-center justify-between gap-2">
<span class="text-sm text-muted-foreground whitespace-nowrap">共 {{ total }} 条</span>
<Pagination
:total="total"
:items-per-page="size"
+36
View File
@@ -0,0 +1,36 @@
<script setup lang="ts">
import { useLoadingBar } from '@/composables/useLoadingBar'
const { progress, visible, status } = useLoadingBar()
</script>
<template>
<Transition name="loading-bar">
<div
v-if="visible"
class="fixed top-0 left-0 right-0 z-[9999] h-[2px] pointer-events-none"
>
<div
class="h-full transition-all duration-200 ease-out"
:class="[
status === 'error' ? 'bg-destructive' : 'bg-primary',
]"
:style="{ width: progress + '%' }"
>
<div
v-if="status === 'loading'"
class="absolute right-0 top-0 h-full w-24 opacity-60 blur-sm bg-primary"
/>
</div>
</div>
</Transition>
</template>
<style scoped>
.loading-bar-leave-active {
transition: opacity 0.3s ease;
}
.loading-bar-leave-to {
opacity: 0;
}
</style>
+8 -5
View File
@@ -61,11 +61,14 @@ async function onCaptchaConfirm(result: { captchaKey: string; dots: Array<{ x: n
toast.success(res.message || '登录成功')
captchaOpen.value = false
await router.push('/')
} catch {
// 业务错误已由拦截器统一 toast 提示
// 重新加载验证码
captchaOpen.value = false
setTimeout(() => { captchaOpen.value = true }, 100)
} catch (err: any) {
// 10003 为验证码错误,刷新验证码继续验证;其他错误关闭弹窗
if (err?.code === 10003) {
captchaOpen.value = false
setTimeout(() => { captchaOpen.value = true }, 100)
} else {
captchaOpen.value = false
}
} finally {
submitting.value = false
}
+53
View File
@@ -0,0 +1,53 @@
import { ref } from 'vue'
const progress = ref(0)
const visible = ref(false)
const status = ref<'loading' | 'done' | 'error'>('loading')
let timer: ReturnType<typeof setInterval> | null = null
function clearTimer() {
if (timer) {
clearInterval(timer)
timer = null
}
}
function start() {
clearTimer()
progress.value = 0
status.value = 'loading'
visible.value = true
// 模拟进度:快速到 80%,然后减速
timer = setInterval(() => {
if (progress.value < 80) {
progress.value += (80 - progress.value) * 0.1
} else if (progress.value < 95) {
progress.value += 0.5
}
}, 100)
}
function finish() {
clearTimer()
status.value = 'done'
progress.value = 100
setTimeout(() => {
visible.value = false
progress.value = 0
}, 300)
}
function error() {
clearTimer()
status.value = 'error'
progress.value = 100
setTimeout(() => {
visible.value = false
progress.value = 0
}, 800)
}
export function useLoadingBar() {
return { progress, visible, status, start, finish, error }
}
+16 -3
View File
@@ -3,6 +3,7 @@ import { useAuthStore } from '@/stores/auth'
import { usePermissionStore } from '@/stores/permission'
import { useUserStore } from '@/stores/user'
import { generateRoutesFromMenu } from './dynamic-routes'
import { useLoadingBar } from '@/composables/useLoadingBar'
/** 白名单路由:无需登录即可访问 */
const whiteList = [
@@ -12,6 +13,7 @@ const whiteList = [
'/reset-password',
'/confirm',
'/balance-migrate',
'/cpdaily/authorize',
]
/** 标记是否已加载过动态路由(避免重复加载) */
@@ -24,7 +26,10 @@ export function resetRouteState() {
/** 注册全局前置守卫 */
export function setupGuards(router: Router) {
const loadingBar = useLoadingBar()
router.beforeEach(async (to, _from, next) => {
loadingBar.start()
const authStore = useAuthStore()
const permStore = usePermissionStore()
@@ -57,8 +62,8 @@ export function setupGuards(router: Router) {
}
dynamicRoutesLoaded = true
// 重新导航以匹配新添加的路由
next({ ...to, replace: true })
// 重新导航以匹配新添加的路由(用 path 而非展开 to,避免 name 导航跳过子路由匹配)
next({ path: to.fullPath, replace: true })
} catch {
// 权限加载失败 → 清除 token 跳转登录
authStore.logout()
@@ -70,11 +75,19 @@ export function setupGuards(router: Router) {
next()
} else {
// 未登录 → 白名单放行,其他跳转登录
if (whiteList.includes(to.path)) {
if (whiteList.some(p => to.path === p || to.path.startsWith(p + '/'))) {
next()
} else {
next('/login')
}
}
})
router.afterEach(() => {
setTimeout(() => loadingBar.finish(), 200)
})
router.onError(() => {
loadingBar.error()
})
}
+8 -2
View File
@@ -21,13 +21,13 @@ export const staticRoutes: RouteRecordRaw[] = [
meta: { title: '忘记密码' },
},
{
path: '/reset-password',
path: '/reset-password/:token',
name: 'resetPassword',
component: () => import('@/views/login/ResetPassword.vue'),
meta: { title: '重置密码' },
},
{
path: '/confirm',
path: '/confirm/:token',
name: 'confirmEmail',
component: () => import('@/views/login/ConfirmEmail.vue'),
meta: { title: '邮箱确认' },
@@ -38,6 +38,12 @@ export const staticRoutes: RouteRecordRaw[] = [
component: () => import('@/views/login/BalanceMigrate.vue'),
meta: { title: '积分转移' },
},
{
path: '/cpdaily/authorize/:uuid',
name: 'cpDeviceAuthorize',
component: () => import('@/views/cpdaily/CpDeviceAuthorize.vue'),
meta: { title: '设备授权' },
},
{
path: '/',
name: 'layout',
+44 -3
View File
@@ -88,9 +88,16 @@ export interface CpLeave {
export interface CpTaskLog {
id: number
taskId: number
result: string
created_at: string
cp_task_id?: number
system_user_id?: number
cp_user_id?: number
is_success?: boolean
real_name?: string
real_title?: string
error_message?: string
createTime?: string
created_at?: string
updateTime?: string
}
export interface CpSchool {
@@ -183,3 +190,37 @@ export interface CpUserCustomDeviceParams {
mobileCode: string
mobileType: 'random' | 'manual'
}
export interface CpLeaveType {
id: string
code: number
name: string
mustOut: number
reasonWordLimit: number
attachmentNumberLimit: number
}
export interface CpLeaveApprover {
userWid: string
userId: string
userName: string
}
export interface CpLeaveApplyParams {
cpUserId: number
leaveType: number
startTime: string
endTime: string
urgencyMobile: string
leaveReason: string
outStatus: boolean
address: string
detailAddress: string
longitude: string
latitude: string
locationAddr: string
imageUrl?: string
approverWid?: string
approverId?: string
approverName?: string
}
+4 -4
View File
@@ -83,8 +83,8 @@ const onAddSubmit = handleAddSubmit(async (values) => {
toast.success('生成卡密成功')
addDialogOpen.value = false
tableRef.value?.refresh()
} catch (err: any) {
toast.error(err.response?.data?.message || '生成卡密失败')
} catch {
// 全局拦截器已处理错误提示
} finally {
addLoading.value = false
}
@@ -97,8 +97,8 @@ function handleBan(id: number) {
await cardApi.ban(id)
toast.success('禁用成功')
tableRef.value?.refresh()
} catch (err: any) {
toast.error(err.response?.data?.message || '禁用失败')
} catch {
// 全局拦截器已处理错误提示
}
})
}
+312
View File
@@ -0,0 +1,312 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import { cpUserApi } from '@/api/cpdaily'
const route = useRoute()
const uuid = route.params.uuid as string
type PageState = 'loading' | 'invalid' | 'not-cpdaily' | 'fetching' | 'success' | 'error'
const state = ref<PageState>('loading')
const deviceCode = ref('')
const errorMsg = ref('')
const copied = ref(false)
function isCpdaily(): boolean {
const ua = navigator.userAgent.toLowerCase()
return ua.includes('cpdaily') || ua.includes('wisedu') || ua.includes('campushoy')
}
async function copyCode() {
if (!deviceCode.value) return
try {
await navigator.clipboard.writeText(deviceCode.value)
} catch {
const el = document.createElement('textarea')
el.value = deviceCode.value
el.style.cssText = 'position:fixed;opacity:0'
document.body.appendChild(el)
el.select()
document.execCommand('copy')
document.body.removeChild(el)
}
copied.value = true
setTimeout(() => { copied.value = false }, 2000)
}
function loadCordova(): Promise<void> {
return new Promise((resolve, reject) => {
const isAndroid = /Android|Linux/.test(navigator.userAgent)
const src = 'https://static.campushoy.com/innerjs/'
+ (isAndroid ? 'cordova_android.js' : 'cordova_ios.js')
const s = document.createElement('script')
s.src = src
s.onload = () => resolve()
s.onerror = () => reject(new Error('脚本加载失败'))
document.head.appendChild(s)
if (!(window as any).mamp) (window as any).mamp = {}
;(window as any).mamp.initEnv = (cb: () => void) =>
document.addEventListener('deviceready', cb, false)
})
}
function waitCordova(): Promise<void> {
return new Promise((resolve, reject) => {
let n = 0
const t = setInterval(() => {
if ((window as any).cordova?.exec) {
clearInterval(t)
resolve()
} else if (++n > 600) {
clearInterval(t)
reject(new Error('Cordova 加载超时'))
}
}, 100)
})
}
function getDeviceUuid(): Promise<string> {
return new Promise((resolve, reject) => {
;(window as any).cordova.exec(
(res: any) => resolve(res.uuid),
(err: any) => reject(new Error(err?.message || String(err))),
'MAMPDevice', 'getDeviceInfo', []
)
})
}
onMounted(async () => {
// 1. 验证UUID有效性
try {
await cpUserApi.checkQr(uuid)
} catch {
state.value = 'invalid'
return
}
// 2. 检测是否在今日校园App内
if (!isCpdaily()) {
state.value = 'not-cpdaily'
return
}
// 3. 加载cordova获取设备码
state.value = 'fetching'
try {
await loadCordova()
await waitCordova()
deviceCode.value = await getDeviceUuid()
state.value = 'success'
} catch (e: any) {
errorMsg.value = e.message || '未知错误'
state.value = 'error'
}
})
</script>
<template>
<div class="authorize-page">
<div class="aurora-orb aurora-orb-1" />
<div class="aurora-orb aurora-orb-2" />
<div class="container">
<div class="glass-card">
<!-- 加载中 -->
<template v-if="state === 'loading'">
<div class="icon-wrap icon-loading">📱</div>
<h1>正在验证...</h1>
<p class="desc">请稍候</p>
<div class="spinner" />
</template>
<!-- 二维码无效 -->
<template v-else-if="state === 'invalid'">
<div class="icon-wrap icon-error">✕</div>
<h1>二维码无效</h1>
<p class="err-text">二维码无效或已过期,请重新获取</p>
</template>
<!-- 非今日校园App -->
<template v-else-if="state === 'not-cpdaily'">
<div class="icon-wrap icon-warn">📵</div>
<h1>请用今日校园扫码</h1>
<p class="desc">此页面需要在<strong>今日校园 App</strong>内打开才能获取设备码</p>
<div class="steps">
<div class="step">
<div class="step-num">1</div>
<div class="step-text">打开今日校园 App<span>在手机上启动今日校园</span></div>
</div>
<div class="step">
<div class="step-num">2</div>
<div class="step-text">点击右上角扫一扫<span>使用 App 内置扫码功能</span></div>
</div>
<div class="step">
<div class="step-num">3</div>
<div class="step-text">扫描电脑上的二维码<span>扫码后设备码将自动显示</span></div>
</div>
</div>
</template>
<!-- 正在获取设备码 -->
<template v-else-if="state === 'fetching'">
<div class="icon-wrap icon-loading">📱</div>
<h1>正在获取设备码</h1>
<p class="desc">请稍候,正在读取设备信息...</p>
<div class="spinner" />
</template>
<!-- 获取成功 -->
<template v-else-if="state === 'success'">
<div class="icon-wrap icon-success">✓</div>
<h1>获取成功</h1>
<p class="desc">已成功获取设备码,请复制后转发给系统管理员</p>
<div class="result-section">
<div class="result-label">设备码</div>
<div class="code-box">{{ deviceCode }}</div>
<button class="btn-primary" @click="copyCode">
<span>📋</span>
<span>{{ copied ? '已复制 ✓' : '复制设备码' }}</span>
</button>
</div>
<p class="footer-note">请及时复制,关闭页面后需重新扫码获取</p>
</template>
<!-- 获取失败 -->
<template v-else-if="state === 'error'">
<div class="icon-wrap icon-error">✕</div>
<h1>获取失败</h1>
<p class="err-text">{{ errorMsg }}</p>
</template>
</div>
</div>
</div>
</template>
<style scoped>
.authorize-page {
font-family: -apple-system, BlinkMacSystemFont, "SF Pro Display", "PingFang SC", "Helvetica Neue", Arial, sans-serif;
background: #F5F5F7;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
overflow-x: hidden;
position: relative;
}
.aurora-orb {
position: fixed;
border-radius: 50%;
filter: blur(100px);
opacity: 0.6;
pointer-events: none;
z-index: 0;
animation: float 20s ease-in-out infinite;
}
.aurora-orb-1 {
width: 60vw; height: 60vw;
background: radial-gradient(circle, rgba(162,210,255,0.4), transparent);
top: -20%; left: -10%;
}
.aurora-orb-2 {
width: 55vw; height: 55vw;
background: radial-gradient(circle, rgba(200,180,255,0.3), transparent);
bottom: -20%; right: -10%;
animation-delay: -10s;
}
@keyframes float {
0%,100% { transform: translate(0,0) scale(1); }
50% { transform: translate(30px,-30px) scale(1.1); }
}
.container {
position: relative;
z-index: 1;
width: 100%;
max-width: 380px;
padding: 20px;
}
.glass-card {
background: rgba(255,255,255,0.82);
backdrop-filter: blur(20px) saturate(180%);
-webkit-backdrop-filter: blur(20px) saturate(180%);
border: 1px solid rgba(255,255,255,0.6);
border-radius: 24px;
box-shadow: 0 8px 32px rgba(0,0,0,0.08);
padding: 36px 28px 32px;
text-align: center;
animation: fadeInUp 0.6s cubic-bezier(0.25,1,0.5,1) 0.05s backwards;
}
@keyframes fadeInUp {
from { opacity: 0; transform: translateY(24px); }
to { opacity: 1; transform: translateY(0); }
}
.icon-wrap {
width: 72px; height: 72px;
border-radius: 20px;
display: flex; align-items: center; justify-content: center;
font-size: 36px;
margin: 0 auto 20px;
}
.icon-loading { background: linear-gradient(135deg,#0071e3,#40a9ff); box-shadow: 0 4px 16px rgba(0,113,227,0.3); }
.icon-warn { background: linear-gradient(135deg,#ff9f0a,#ffcc02); box-shadow: 0 4px 16px rgba(255,159,10,0.3); }
.icon-success { background: linear-gradient(135deg,#34c759,#30d158); box-shadow: 0 4px 16px rgba(52,199,89,0.3); }
.icon-error { background: linear-gradient(135deg,#ff3b30,#ff6961); box-shadow: 0 4px 16px rgba(255,59,48,0.3); }
h1 { font-size: 22px; font-weight: 700; letter-spacing: -0.02em; color: #1d1d1f; margin-bottom: 10px; }
.desc { font-size: 15px; color: #86868b; line-height: 1.6; }
.desc strong { color: #1d1d1f; font-weight: 600; }
.spinner {
width: 36px; height: 36px;
border: 3px solid rgba(0,113,227,0.15);
border-top-color: #0071e3;
border-radius: 50%;
animation: spin 0.8s linear infinite;
margin: 20px auto 0;
}
@keyframes spin { to { transform: rotate(360deg); } }
.steps { margin: 20px 0 0; display: flex; flex-direction: column; gap: 10px; text-align: left; }
.step {
display: flex; align-items: flex-start; gap: 12px;
background: rgba(0,0,0,0.03); border-radius: 12px;
padding: 12px 14px; border: 1px solid rgba(0,0,0,0.05);
}
.step-num {
width: 22px; height: 22px; border-radius: 50%;
background: #0071e3; color: white;
font-size: 12px; font-weight: 700;
display: flex; align-items: center; justify-content: center;
flex-shrink: 0; margin-top: 1px;
}
.step-text { font-size: 14px; color: #1d1d1f; line-height: 1.5; }
.step-text span { color: #86868b; font-size: 13px; display: block; margin-top: 2px; }
.err-text { font-size: 15px; color: #d70015; margin-top: 16px; line-height: 1.5; }
.result-section {
margin-top: 20px;
display: flex; flex-direction: column; gap: 14px;
}
.result-label {
font-size: 11px; font-weight: 600;
letter-spacing: 0.07em; text-transform: uppercase;
color: #86868b; text-align: left;
}
.code-box {
background: #F2F2F7; border-radius: 12px;
padding: 14px 16px;
font-family: "SF Mono", "Fira Code", monospace;
font-size: 13px; color: #1d1d1f;
word-break: break-all; line-height: 1.7;
border: 1px solid rgba(0,0,0,0.06);
user-select: all; text-align: left;
}
.btn-primary {
background: #0071e3; color: white;
padding: 13px 24px; border-radius: 980px; border: none;
font-size: 15px; font-weight: 500; cursor: pointer;
transition: all 0.3s cubic-bezier(0.25,1,0.5,1);
width: 100%;
display: flex; align-items: center; justify-content: center; gap: 8px;
}
.btn-primary:hover { transform: scale(1.02); box-shadow: 0 8px 24px rgba(0,113,227,0.3); filter: brightness(1.08); }
.btn-primary:active { transform: scale(0.98); }
.footer-note {
text-align: center; font-size: 13px; color: #86868b;
margin-top: 16px; line-height: 1.5;
}
</style>
File diff suppressed because it is too large Load Diff
+9 -9
View File
@@ -198,7 +198,7 @@ async function handleImageUpload(field: CpTaskFormItem, event: Event) {
list[idx] = { ...list[idx], url: previewUrl, previewUrl, status: 'finished' }
updateImageContent(field)
} else { removeImage(field, id); toast.error('上传失败') }
} catch { removeImage(field, id); toast.error('上传失败') }
} catch { removeImage(field, id) }
}
input.value = ''
}
@@ -262,8 +262,8 @@ async function loadForm() {
}
if (field.type === 4) initImageField(field)
})
} catch (err: any) {
toast.error(err.response?.data?.message || '获取表单数据失败')
} catch {
// 全局拦截器已处理错误提示
} finally { formLoading.value = false }
}
@@ -281,8 +281,8 @@ async function loadExecute() {
execute_hour: data.execute_hour ?? 8, execute_minute: data.execute_minute ?? 0,
execute_days: Array.isArray(data.execute_days) && data.execute_days.length ? data.execute_days : [1, 2, 3, 4, 5, 6, 7],
}
} catch (err: any) {
toast.error(err.response?.data?.message || '获取执行参数失败')
} catch {
// 全局拦截器已处理错误提示
} finally { executeLoading.value = false }
}
@@ -298,8 +298,8 @@ async function saveForm() {
const formData = formItems.value.map(f => ({ ...f, content: f.content || '', otherValue: f.otherValue || '' }))
await cpTaskApi.updateForm(taskId.value, formData)
toast.success('表单保存成功')
} catch (err: any) {
toast.error(err.response?.data?.message || '保存表单失败')
} catch {
// 全局拦截器已处理错误提示
} finally { savingForm.value = false }
}
@@ -314,8 +314,8 @@ async function saveExecute() {
execute_days: executeParams.value.execute_days,
})
toast.success('执行参数保存成功')
} catch (err: any) {
toast.error(err.response?.data?.message || '保存执行参数失败')
} catch {
// 全局拦截器已处理错误提示
} finally { savingExecute.value = false }
}
+31 -5
View File
@@ -18,18 +18,44 @@ const taskId = computed(() => Number(route.params.id))
const columns: ColumnDef<CpTaskLog, any>[] = [
{ accessorKey: 'id', header: 'ID' },
{
accessorKey: 'result',
accessorKey: 'is_success',
header: '执行结果',
cell: ({ row }) => {
const result = row.getValue<string>('result')
return h('span', { class: 'whitespace-pre-wrap break-all' }, result || '-')
const isSuccess = row.original.is_success
const errorMessage = row.original.error_message
if (typeof isSuccess === 'boolean') {
return h(
'span',
{ class: isSuccess ? 'text-green-600' : 'text-red-600' },
isSuccess ? '成功' : '失败',
)
}
if (errorMessage) {
return h('span', { class: 'text-red-600' }, '失败')
}
return h('span', '-')
},
},
{
accessorKey: 'created_at',
accessorKey: 'real_name',
header: '成功标题',
cell: ({ row }) => {
const realTitle = row.original.real_name || row.original.real_title
return h('span', { class: 'whitespace-pre-wrap break-all' }, realTitle || '-')
},
},
{
accessorKey: 'error_message',
header: '失败原因',
cell: ({ row }) => {
return h('span', { class: 'whitespace-pre-wrap break-all' }, row.original.error_message || '-')
},
},
{
accessorKey: 'createTime',
header: '执行时间',
cell: ({ row }) => {
const val = row.getValue<string>('created_at')
const val = row.original.createTime || row.original.created_at
return val ? val.replace('T', ' ').slice(0, 19) : '-'
},
},
+44 -12
View File
@@ -1,8 +1,8 @@
<script setup lang="ts">
import { ref, h, watch } from 'vue'
import { ref, h, watch, onMounted } from 'vue'
import type { ColumnDef } from '@tanstack/vue-table'
import { message as toast } from '@/composables/useMessage'
import { useRouter } from 'vue-router'
import { useRouter, useRoute } from 'vue-router'
import { PlusCircle, Play, Pause, Trash2, Pencil, ScrollText } from 'lucide-vue-next'
import DataTable from '@/components/common/DataTable.vue'
import type { SearchField } from '@/components/common/DataTable.vue'
@@ -25,6 +25,7 @@ import type { PageParams, PageResponse } from '@/types/api'
import { extractPageData } from '@/types/api'
const router = useRouter()
const route = useRoute()
const tableRef = ref<{ refresh: () => void } | null>(null)
// ---- 确认弹窗状态 ----
@@ -59,9 +60,14 @@ const userSearchText = ref('')
const userSearchResults = ref<{ label: string; value: number }[]>([])
const userSearching = ref(false)
let userSearchTimer: ReturnType<typeof setTimeout> | null = null
let skipNextSearch = false
watch(userSearchText, (val) => {
if (userSearchTimer) clearTimeout(userSearchTimer)
if (skipNextSearch) {
skipNextSearch = false
return
}
if (!val || val.length < 1) {
userSearchResults.value = []
return
@@ -86,6 +92,7 @@ watch(userSearchText, (val) => {
function selectUser(user: { label: string; value: number }) {
addUserId.value = user.value
skipNextSearch = true
userSearchText.value = user.label
userSearchResults.value = []
}
@@ -139,8 +146,8 @@ async function handleQueryRecent() {
const res = await cpTaskApi.getRecent({ userId: addUserId.value, type: addType.value })
recentTasks.value = res.data || []
addStep.value = 2
} catch (err: any) {
toast.error(err.response?.data?.message || '查询失败')
} catch {
// 全局拦截器已处理错误提示
} finally {
recentLoading.value = false
}
@@ -159,6 +166,31 @@ function openAddDialog() {
addDialogOpen.value = true
}
// 支持从用户管理页面跳转过来时自动打开弹窗并预填用户
function openAddDialogWithUser(userId: number, username: string) {
addStep.value = 1
addUserId.value = userId
addType.value = ''
addTitle.value = ''
selectedRecent.value = ''
recentTasks.value = []
skipNextSearch = true
userSearchText.value = username
userSearchResults.value = []
isMatched.value = false
addDialogOpen.value = true
}
onMounted(() => {
const qUserId = route.query.addUserId as string
const qUsername = route.query.addUsername as string
if (qUserId && qUsername) {
openAddDialogWithUser(Number(qUserId), qUsername)
// 清除 query 参数,避免刷新时重复弹窗
router.replace({ path: route.path })
}
})
function prevStep() {
addStep.value = 1
selectedRecent.value = ''
@@ -181,8 +213,8 @@ async function handleSaveTask() {
toast.success('添加任务成功')
addDialogOpen.value = false
tableRef.value?.refresh()
} catch (err: any) {
toast.error(err.response?.data?.message || '添加任务失败')
} catch {
// 全局拦截器已处理错误提示
} finally {
addLoading.value = false
}
@@ -194,8 +226,8 @@ async function handleExecuteNow(id: number) {
await cpTaskApi.executeNow(id)
toast.success('任务已开始执行')
tableRef.value?.refresh()
} catch (err: any) {
toast.error(err.response?.data?.message || '执行失败')
} catch {
// 全局拦截器已处理错误提示
}
}
@@ -206,8 +238,8 @@ function handlePause(id: number, currentStatus: string) {
await cpTaskApi.pause(id)
toast.success(`${action}成功`)
tableRef.value?.refresh()
} catch (err: any) {
toast.error(err.response?.data?.message || `${action}失败`)
} catch {
// 全局拦截器已处理错误提示
}
})
}
@@ -218,8 +250,8 @@ function handleDelete(id: number) {
await cpTaskApi.delete(id)
toast.success('删除成功')
tableRef.value?.refresh()
} catch (err: any) {
toast.error(err.response?.data?.message || '删除失败')
} catch {
// 全局拦截器已处理错误提示
}
})
}
+128 -200
View File
@@ -1,11 +1,13 @@
<script setup lang="ts">
import { ref, h, watch } from 'vue'
import { ref, h, watch, nextTick, onUnmounted } from 'vue'
import { useRouter } from 'vue-router'
import type { ColumnDef } from '@tanstack/vue-table'
import { message as toast } from '@/composables/useMessage'
import { useForm } from 'vee-validate'
import { toTypedSchema } from '@vee-validate/zod'
import * as z from 'zod'
import { PlusCircle, Trash2, KeyRound, Smartphone, QrCode } from 'lucide-vue-next'
import QRCode from 'qrcode'
import { PlusCircle, Trash2, KeyRound, Smartphone, QrCode, Loader2, ListPlus } from 'lucide-vue-next'
import DataTable from '@/components/common/DataTable.vue'
import type { SearchField } from '@/components/common/DataTable.vue'
import IconButton from '@/components/common/IconButton.vue'
@@ -30,6 +32,16 @@ import type { PageParams, PageResponse } from '@/types/api'
import { extractPageData } from '@/types/api'
const tableRef = ref<{ refresh: () => void } | null>(null)
const router = useRouter()
// 生成 UUID v4
function generateUUID(): string {
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
const r = (Math.random() * 16) | 0
const v = c === 'x' ? r : (r & 0x3) | 0x8
return v.toString(16)
})
}
// ---- 确认弹窗状态 ----
const confirmOpen = ref(false)
@@ -70,7 +82,6 @@ function resetSchoolSearch() {
watch(schoolSearchText, (val) => {
if (schoolSearchTimer) clearTimeout(schoolSearchTimer)
// 如果用户清空了搜索框,也清空选中
if (!val) {
selectedSchool.value = null
schoolResults.value = []
@@ -82,7 +93,6 @@ watch(schoolSearchText, (val) => {
schoolDropdownOpen.value = false
return
}
// 如果已选中且文本没变,不再搜索
if (selectedSchool.value && val === selectedSchool.value.name) return
schoolSearchTimer = setTimeout(async () => {
schoolSearching.value = true
@@ -140,7 +150,6 @@ const [addUsername, addUsernameAttrs] = defineAddField('username')
const [addPassword, addPasswordAttrs] = defineAddField('password')
const [addEmail, addEmailAttrs] = defineAddField('email')
// 多因子确认所需的暂存数据
const confirmAddKey = ref('')
const confirmAddUsername = ref('')
const confirmAddPassword = ref('')
@@ -184,7 +193,7 @@ const onAddByPasswordSubmit = handleAddSubmit(async (values) => {
tableRef.value?.refresh()
}
} catch (err: any) {
toast.error(err.response?.data?.message || '添加账号失败')
// 全局拦截器已处理错误提示
} finally {
addLoading.value = false
}
@@ -200,7 +209,6 @@ const mobileCodeSent = ref(false)
const mobileCountdown = ref(0)
let countdownTimer: ReturnType<typeof setInterval> | null = null
// 滑块验证码状态
const captchaOpen = ref(false)
const captchaStep = ref<'getCode' | 'login'>('getCode')
@@ -223,7 +231,6 @@ function handleGetCodeClick() {
}
if (!validateSchool()) return
captchaStep.value = 'getCode'
// 先关闭添加弹窗,避免两层遮罩叠加黑屏
addDialogOpen.value = false
setTimeout(() => { captchaOpen.value = true }, 150)
}
@@ -231,7 +238,6 @@ function handleGetCodeClick() {
async function onCaptchaConfirm(result: { captchaKey: string; dots: Array<{ x: number; y: number }> }) {
captchaOpen.value = false
if (captchaStep.value === 'getCode') {
// 获取验证码
mobileCodeLoading.value = true
try {
await cpUserApi.getCode({
@@ -243,14 +249,12 @@ async function onCaptchaConfirm(result: { captchaKey: string; dots: Array<{ x: n
mobileCodeSent.value = true
startCountdown()
} catch (err: any) {
toast.error(err.response?.data?.message || '获取验证码失败')
// 全局拦截器已处理错误提示
} finally {
mobileCodeLoading.value = false
// 重新打开添加弹窗
addDialogOpen.value = true
}
} else {
// 登录/添加
mobileAddLoading.value = true
try {
await cpUserApi.addByCode({
@@ -262,11 +266,8 @@ async function onCaptchaConfirm(result: { captchaKey: string; dots: Array<{ x: n
email: mobileEmail.value || undefined,
})
toast.success('添加账号成功')
// 成功后不再打开添加弹窗
tableRef.value?.refresh()
} catch (err: any) {
toast.error(err.response?.data?.message || '添加失败')
// 失败后重新打开添加弹窗
addDialogOpen.value = true
} finally {
mobileAddLoading.value = false
@@ -274,7 +275,6 @@ async function onCaptchaConfirm(result: { captchaKey: string; dots: Array<{ x: n
}
}
// 验证码弹窗关闭时(用户手动关闭),恢复添加弹窗
function onCaptchaClose(open: boolean) {
captchaOpen.value = open
if (!open && !mobileCodeLoading.value && !mobileAddLoading.value) {
@@ -289,7 +289,6 @@ function onAddByMobileSubmit() {
}
if (!validateSchool()) return
captchaStep.value = 'login'
// 先关闭添加弹窗
addDialogOpen.value = false
setTimeout(() => { captchaOpen.value = true }, 150)
}
@@ -319,7 +318,7 @@ async function onConfirmAddSubmit() {
dynamicCode.value = ''
tableRef.value?.refresh()
} catch (err: any) {
toast.error(err.response?.data?.message || '验证失败')
// 全局拦截器已处理错误提示
} finally {
confirmAddLoading.value = false
}
@@ -361,7 +360,7 @@ const onChangePwdSubmit = handleChangePwdSubmit(async (values) => {
toast.success('密码修改成功')
changePwdOpen.value = false
} catch (err: any) {
toast.error(err.response?.data?.message || '修改密码失败')
// 全局拦截器已处理错误提示
} finally {
changePwdLoading.value = false
}
@@ -375,60 +374,11 @@ function handleDelete(id: number) {
toast.success('删除成功')
tableRef.value?.refresh()
} catch (err: any) {
toast.error(err.response?.data?.message || '删除失败')
// 全局拦截器已处理错误提示
}
})
}
// ---- 设备授权弹窗 ----
const authDeviceOpen = ref(false)
const authDeviceStep = ref(1)
const authDeviceUserId = ref(0)
const authDeviceLoading = ref(false)
const authDataKey = ref('')
const authConfirmLoading = ref(false)
function openAuthDeviceDialog(id: number) {
authDeviceUserId.value = id
authDeviceStep.value = 1
authDataKey.value = ''
authDeviceOpen.value = true
}
async function handleAuthorizeDevice() {
authDeviceLoading.value = true
try {
await cpUserApi.authorizeDevice(authDeviceUserId.value)
toast.success('授权请求已发送,请输入授权码')
authDeviceStep.value = 2
} catch (err: any) {
toast.error(err.response?.data?.message || '授权失败')
} finally {
authDeviceLoading.value = false
}
}
async function onAuthorizeConfirm() {
if (!authDataKey.value) {
toast.error('请输入授权码')
return
}
authConfirmLoading.value = true
try {
await cpUserApi.authorizeConfirm({
id: authDeviceUserId.value,
dataKey: authDataKey.value,
})
toast.success('设备授权成功')
authDeviceOpen.value = false
tableRef.value?.refresh()
} catch (err: any) {
toast.error(err.response?.data?.message || '授权确认失败')
} finally {
authConfirmLoading.value = false
}
}
// ---- 自定义设备码弹窗 ----
const customDeviceOpen = ref(false)
const customDeviceLoading = ref(false)
@@ -459,12 +409,56 @@ async function onCustomDeviceSubmit() {
customDeviceOpen.value = false
tableRef.value?.refresh()
} catch (err: any) {
toast.error(err.response?.data?.message || '设置失败')
// 全局拦截器已处理错误提示
} finally {
customDeviceLoading.value = false
}
}
// ---- 二维码获取设备码弹窗 ----
const qrDialogOpen = ref(false)
const qrLoading = ref(false)
const qrUuid = ref('')
async function openQrDialog() {
qrUuid.value = ''
qrDialogOpen.value = true
qrLoading.value = true
try {
const res = await cpUserApi.generateQr()
qrUuid.value = res.data.uuid
} catch (err: any) {
qrDialogOpen.value = false
} finally {
qrLoading.value = false
}
}
// 获取二维码内容URL(App扫码后打开前端授权页)
function getQrCodeUrl() {
return `${window.location.origin}/cpdaily/authorize/${qrUuid.value}`
}
// QR 码 canvas 渲染
const qrCanvasRef = ref<HTMLCanvasElement | null>(null)
watch(qrUuid, async (uuid) => {
if (uuid) {
await nextTick()
if (qrCanvasRef.value) {
QRCode.toCanvas(qrCanvasRef.value, getQrCodeUrl(), {
width: 200,
margin: 1,
color: { dark: '#000000', light: '#ffffff' },
})
}
}
})
onUnmounted(() => {
if (countdownTimer) clearInterval(countdownTimer)
})
// ---- 表格列定义 ----
const columns: ColumnDef<CpUser, any>[] = [
{
@@ -492,13 +486,10 @@ const columns: ColumnDef<CpUser, any>[] = [
header: '授权方式',
cell: ({ row }) => {
const type = row.original.mobile_type
const map: Record<string, { label: string; variant: 'default' | 'secondary' | 'outline' }> = {
random: { label: '随机', variant: 'outline' },
authorize: { label: '授权', variant: 'default' },
manual: { label: '手动', variant: 'secondary' },
if (type === 'manual') {
return h(Badge, { variant: 'default' }, () => '手动')
}
const config = map[type] || { label: type || '未知', variant: 'outline' as const }
return h(Badge, { variant: config.variant }, () => config.label)
return h(Badge, { variant: 'secondary' }, () => '随机')
},
},
{
@@ -521,9 +512,9 @@ const columns: ColumnDef<CpUser, any>[] = [
const user = row.original
return h('div', { class: 'flex items-center gap-1' }, [
h(IconButton, {
icon: QrCode,
tooltip: '授权',
onClick: () => openAuthDeviceDialog(user.id),
icon: ListPlus,
tooltip: '添加任务',
onClick: () => router.push({ path: '/cpdaily/task', query: { addUserId: String(user.id), addUsername: user.username } }),
}),
h(IconButton, {
icon: Smartphone,
@@ -559,11 +550,17 @@ const fetchFn = async (params: PageParams): Promise<PageResponse<CpUser>> => {
<template>
<div class="space-y-6">
<div class="flex items-center justify-between">
<h2 class="text-2xl font-bold tracking-tight">用号管理</h2>
<Button size="sm" @click="openAddDialog">
<PlusCircle class="mr-1 h-4 w-4" />
添加账号
</Button>
<h2 class="text-2xl font-bold tracking-tight">用户管理</h2>
<div class="flex items-center gap-2">
<Button size="sm" variant="outline" @click="openQrDialog">
<QrCode class="mr-1 h-4 w-4" />
生成二维码
</Button>
<Button size="sm" @click="openAddDialog">
<PlusCircle class="mr-1 h-4 w-4" />
添加账号
</Button>
</div>
</div>
<DataTable
@@ -601,32 +598,14 @@ const fetchFn = async (params: PageParams): Promise<PageResponse<CpUser>> => {
<!-- 账号密码添加 -->
<TabsContent value="password">
<form class="space-y-4" @submit.prevent="onAddByPasswordSubmit">
<!-- 学校搜索(必填,置顶) -->
<div class="space-y-2 relative">
<Label>学校 <span class="text-destructive">*</span></Label>
<Input
v-model="schoolSearchText"
placeholder="输入学校名称搜索(至少2个字)"
autocomplete="off"
/>
<Input v-model="schoolSearchText" placeholder="输入学校名称搜索(至少2个字)" autocomplete="off" />
<div v-if="schoolSearching" class="text-sm text-muted-foreground">搜索中...</div>
<div
v-if="schoolDropdownOpen"
class="absolute z-50 w-full max-h-40 overflow-y-auto rounded-md border bg-popover shadow-md"
style="top: 100%"
>
<div
v-for="s in schoolResults"
:key="s.id"
class="cursor-pointer px-3 py-2 text-sm hover:bg-accent"
@click="selectSchool(s)"
>
{{ s.name }}
</div>
<div v-if="schoolDropdownOpen" class="absolute z-50 w-full max-h-40 overflow-y-auto rounded-md border bg-popover shadow-md" style="top: 100%">
<div v-for="s in schoolResults" :key="s.id" class="cursor-pointer px-3 py-2 text-sm hover:bg-accent" @click="selectSchool(s)">{{ s.name }}</div>
</div>
<p v-if="selectedSchool" class="text-sm text-primary">
已选择:{{ selectedSchool.name }}
</p>
<p v-if="selectedSchool" class="text-sm text-primary">已选择:{{ selectedSchool.name }}</p>
<p v-if="schoolError" class="text-sm text-destructive">{{ schoolError }}</p>
</div>
<div class="space-y-2">
@@ -645,9 +624,7 @@ const fetchFn = async (params: PageParams): Promise<PageResponse<CpUser>> => {
</div>
<DialogFooter>
<Button type="button" variant="outline" @click="addDialogOpen = false">取消</Button>
<Button type="submit" :disabled="addLoading">
{{ addLoading ? '提交中...' : '确认添加' }}
</Button>
<Button type="submit" :disabled="addLoading">{{ addLoading ? '提交中...' : '确认添加' }}</Button>
</DialogFooter>
</form>
</TabsContent>
@@ -655,32 +632,14 @@ const fetchFn = async (params: PageParams): Promise<PageResponse<CpUser>> => {
<!-- 手机号添加 -->
<TabsContent value="mobile">
<form class="space-y-4" @submit.prevent="onAddByMobileSubmit">
<!-- 学校搜索(必填,置顶) -->
<div class="space-y-2 relative">
<Label>学校 <span class="text-destructive">*</span></Label>
<Input
v-model="schoolSearchText"
placeholder="输入学校名称搜索(至少2个字)"
autocomplete="off"
/>
<Input v-model="schoolSearchText" placeholder="输入学校名称搜索(至少2个字)" autocomplete="off" />
<div v-if="schoolSearching" class="text-sm text-muted-foreground">搜索中...</div>
<div
v-if="schoolDropdownOpen"
class="absolute z-50 w-full max-h-40 overflow-y-auto rounded-md border bg-popover shadow-md"
style="top: 100%"
>
<div
v-for="s in schoolResults"
:key="s.id"
class="cursor-pointer px-3 py-2 text-sm hover:bg-accent"
@click="selectSchool(s)"
>
{{ s.name }}
</div>
<div v-if="schoolDropdownOpen" class="absolute z-50 w-full max-h-40 overflow-y-auto rounded-md border bg-popover shadow-md" style="top: 100%">
<div v-for="s in schoolResults" :key="s.id" class="cursor-pointer px-3 py-2 text-sm hover:bg-accent" @click="selectSchool(s)">{{ s.name }}</div>
</div>
<p v-if="selectedSchool" class="text-sm text-primary">
已选择:{{ selectedSchool.name }}
</p>
<p v-if="selectedSchool" class="text-sm text-primary">已选择:{{ selectedSchool.name }}</p>
<p v-if="schoolError" class="text-sm text-destructive">{{ schoolError }}</p>
</div>
<div class="space-y-2">
@@ -691,13 +650,7 @@ const fetchFn = async (params: PageParams): Promise<PageResponse<CpUser>> => {
<Label>验证码</Label>
<div class="flex gap-2">
<Input v-model="mobileCode" placeholder="请输入验证码" class="flex-1" />
<Button
type="button"
variant="outline"
size="sm"
@click="handleGetCodeClick"
:disabled="mobileCodeLoading || mobileCountdown > 0"
>
<Button type="button" variant="outline" size="sm" @click="handleGetCodeClick" :disabled="mobileCodeLoading || mobileCountdown > 0">
{{ mobileCodeLoading ? '发送中...' : mobileCountdown > 0 ? `${mobileCountdown}s` : '获取验证码' }}
</Button>
</div>
@@ -708,9 +661,7 @@ const fetchFn = async (params: PageParams): Promise<PageResponse<CpUser>> => {
</div>
<DialogFooter>
<Button type="button" variant="outline" @click="addDialogOpen = false">取消</Button>
<Button type="submit" :disabled="mobileAddLoading">
{{ mobileAddLoading ? '提交中...' : '确认添加' }}
</Button>
<Button type="submit" :disabled="mobileAddLoading">{{ mobileAddLoading ? '提交中...' : '确认添加' }}</Button>
</DialogFooter>
</form>
</TabsContent>
@@ -732,9 +683,7 @@ const fetchFn = async (params: PageParams): Promise<PageResponse<CpUser>> => {
</div>
<DialogFooter>
<Button type="button" variant="outline" @click="confirmAddOpen = false">取消</Button>
<Button type="submit" :disabled="confirmAddLoading">
{{ confirmAddLoading ? '验证中...' : '确认' }}
</Button>
<Button type="submit" :disabled="confirmAddLoading">{{ confirmAddLoading ? '验证中...' : '确认' }}</Button>
</DialogFooter>
</form>
</DialogContent>
@@ -755,43 +704,12 @@ const fetchFn = async (params: PageParams): Promise<PageResponse<CpUser>> => {
</div>
<DialogFooter>
<Button type="button" variant="outline" @click="changePwdOpen = false">取消</Button>
<Button type="submit" :disabled="changePwdLoading">
{{ changePwdLoading ? '提交中...' : '确认修改' }}
</Button>
<Button type="submit" :disabled="changePwdLoading">{{ changePwdLoading ? '提交中...' : '确认修改' }}</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
<!-- 设备授权弹窗 -->
<Dialog v-model:open="authDeviceOpen">
<DialogContent class="sm:max-w-sm">
<DialogHeader>
<DialogTitle>设备授权</DialogTitle>
<DialogDescription>{{ authDeviceStep === 1 ? '点击开始授权设备' : '请输入授权码完成确认' }}</DialogDescription>
</DialogHeader>
<div class="space-y-4">
<div v-if="authDeviceStep === 1" class="text-center">
<Button @click="handleAuthorizeDevice" :disabled="authDeviceLoading">
{{ authDeviceLoading ? '授权中...' : '开始授权' }}
</Button>
</div>
<form v-else class="space-y-4" @submit.prevent="onAuthorizeConfirm">
<div class="space-y-2">
<Label>授权码 (dataKey)</Label>
<Input v-model="authDataKey" placeholder="请输入授权码" />
</div>
<DialogFooter>
<Button type="button" variant="outline" @click="authDeviceOpen = false">取消</Button>
<Button type="submit" :disabled="authConfirmLoading">
{{ authConfirmLoading ? '确认中...' : '确认授权' }}
</Button>
</DialogFooter>
</form>
</div>
</DialogContent>
</Dialog>
<!-- 自定义设备码弹窗 -->
<Dialog v-model:open="customDeviceOpen">
<DialogContent class="sm:max-w-sm">
@@ -803,40 +721,50 @@ const fetchFn = async (params: PageParams): Promise<PageResponse<CpUser>> => {
<div class="space-y-2">
<Label>设备码类型</Label>
<div class="flex rounded-md border overflow-hidden">
<button
type="button"
class="flex-1 px-4 py-2 text-sm font-medium transition-colors"
:class="customDeviceType === 'manual' ? 'bg-primary text-primary-foreground' : 'bg-background hover:bg-accent'"
@click="customDeviceType = 'manual'"
>
手动
</button>
<button
type="button"
class="flex-1 px-4 py-2 text-sm font-medium transition-colors border-l"
:class="customDeviceType === 'random' ? 'bg-primary text-primary-foreground' : 'bg-background hover:bg-accent'"
@click="customDeviceType = 'random'"
>
随机
</button>
<button type="button" class="flex-1 px-4 py-2 text-sm font-medium transition-colors" :class="customDeviceType === 'manual' ? 'bg-primary text-primary-foreground' : 'bg-background hover:bg-accent'" @click="customDeviceType = 'manual'; customDeviceCode = ''">手动</button>
<button type="button" class="flex-1 px-4 py-2 text-sm font-medium transition-colors border-l" :class="customDeviceType === 'random' ? 'bg-primary text-primary-foreground' : 'bg-background hover:bg-accent'" @click="customDeviceType = 'random'; customDeviceCode = generateUUID()">随机</button>
</div>
</div>
<div class="space-y-2">
<Label>设备码</Label>
<Input
v-model="customDeviceCode"
:placeholder="customDeviceType === 'random' ? '留空则随机生成' : '请输入设备码'"
:disabled="customDeviceType === 'random'"
/>
<Input v-model="customDeviceCode" :placeholder="customDeviceType === 'random' ? '已自动生成' : '请输入设备码'" :disabled="customDeviceType === 'random'" />
</div>
<DialogFooter>
<Button type="button" variant="outline" @click="customDeviceOpen = false">取消</Button>
<Button type="submit" :disabled="customDeviceLoading">
{{ customDeviceLoading ? '提交中...' : '确认' }}
</Button>
<Button type="submit" :disabled="customDeviceLoading">{{ customDeviceLoading ? '提交中...' : '确认' }}</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
<!-- 二维码获取设备码弹窗 -->
<Dialog v-model:open="qrDialogOpen">
<DialogContent class="sm:max-w-sm">
<DialogHeader>
<DialogTitle>获取设备码</DialogTitle>
<DialogDescription>使用今日校园 App 扫描二维码获取设备码</DialogDescription>
</DialogHeader>
<div class="flex flex-col items-center gap-4 py-4">
<!-- 加载中 -->
<template v-if="qrLoading">
<Loader2 class="h-8 w-8 animate-spin text-muted-foreground" />
<p class="text-sm text-muted-foreground">正在生成二维码...</p>
</template>
<!-- 显示二维码 -->
<template v-else-if="qrUuid">
<div class="rounded-lg border bg-white p-3">
<canvas ref="qrCanvasRef" />
</div>
<p class="text-xs text-muted-foreground text-center">
请使用<b><em>今日校园 App</em></b>扫描上方二维码<br>
将扫码后获取到的设备码复制发送给我<br>
二维码有效期 5 分钟
</p>
</template>
</div>
</DialogContent>
</Dialog>
</div>
</template>
+14 -3
View File
@@ -77,7 +77,7 @@ async function fetchAnnouncement() {
announcementLoading.value = true
try {
const res = await authApi.getPublicConfig('system.announcement')
if (res.code === 200 && res.data) announcement.value = String(res.data)
if (res.code === 200 && res.data) announcement.value = res.data.value || ''
} finally {
announcementLoading.value = false
}
@@ -93,8 +93,14 @@ onMounted(() => {
<template>
<div class="space-y-4 sm:space-y-6">
<!-- 公告 -->
<Card v-if="announcement" class="announcement-card border-primary/20 bg-primary/5">
<CardContent class="p-3 sm:p-4">
<Card v-if="announcementLoading">
<CardContent class="space-y-2">
<div class="h-4 w-3/4 rounded bg-muted animate-pulse" />
<div class="h-4 w-1/2 rounded bg-muted animate-pulse" />
</CardContent>
</Card>
<Card v-else-if="announcement">
<CardContent>
<MdPreview :modelValue="announcement" previewTheme="github" class="announcement-preview" />
</CardContent>
</Card>
@@ -221,8 +227,13 @@ onMounted(() => {
<style scoped>
.announcement-preview :deep(.md-editor-preview-wrapper) {
padding: 0;
background: transparent;
}
.announcement-preview :deep(.md-editor-preview) {
font-size: 0.875rem;
background: transparent;
}
.announcement-preview :deep(.md-editor-preview .md-editor-admonition) {
margin: 0;
}
</style>
+6 -3
View File
@@ -49,9 +49,12 @@ async function onRequestCaptchaConfirm(result: { captchaKey: string; dots: Array
toast.success('转移链接已发送到邮箱,请查收')
captchaOpen.value = false
} catch (err: any) {
toast.error(err?.response?.data?.message || '请求失败')
captchaOpen.value = false
setTimeout(() => { captchaOpen.value = true }, 100)
if (err?.code === 10003) {
captchaOpen.value = false
setTimeout(() => { captchaOpen.value = true }, 100)
} else {
captchaOpen.value = false
}
} finally {
requestSubmitting.value = false
}
+30 -11
View File
@@ -1,29 +1,43 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import { useRoute, useRouter } from 'vue-router'
import { Button } from '@/components/ui/button'
import { CheckCircle, XCircle, Loader2 } from 'lucide-vue-next'
import { authApi } from '@/api/auth'
import { message as toast } from '@/composables/useMessage'
const route = useRoute()
const router = useRouter()
const token = ref((route.query.token as string) || '')
const token = ref((route.params.token as string) || '')
const status = ref<'loading' | 'success' | 'error'>('loading')
const message = ref('')
const msg = ref('')
const autoLoginSuccess = ref(false)
onMounted(async () => {
if (!token.value) {
status.value = 'error'
message.value = '缺少确认令牌参数'
msg.value = '缺少确认令牌参数'
return
}
try {
const res = await authApi.confirm(token.value)
status.value = 'success'
message.value = res.message || '邮箱验证成功'
msg.value = res.message || '邮箱验证成功'
// 后端 confirm 接口会通过响应头返回 token(自动登录)
// axios 拦截器已自动将 token 存入 localStorage
const savedToken = localStorage.getItem('token')
if (savedToken) {
autoLoginSuccess.value = true
toast.success('验证成功,正在跳转...')
setTimeout(() => {
router.push('/')
}, 1500)
}
} catch (err: any) {
status.value = 'error'
message.value = err?.response?.data?.message || '邮箱验证失败'
msg.value = err?.response?.data?.message || err?.message || '邮箱验证失败'
}
})
</script>
@@ -39,16 +53,21 @@ onMounted(async () => {
<div v-else-if="status === 'success'" class="space-y-4">
<CheckCircle class="h-12 w-12 mx-auto text-green-500" />
<h1 class="text-xl font-bold">验证成功</h1>
<p class="text-muted-foreground text-sm">{{ message }}</p>
<router-link to="/login">
<Button class="mt-2">前往登录</Button>
</router-link>
<p class="text-muted-foreground text-sm">{{ msg }}</p>
<template v-if="autoLoginSuccess">
<p class="text-sm text-muted-foreground">正在自动跳转...</p>
</template>
<template v-else>
<router-link to="/login">
<Button class="mt-2">前往登录</Button>
</router-link>
</template>
</div>
<div v-else class="space-y-4">
<XCircle class="h-12 w-12 mx-auto text-destructive" />
<h1 class="text-xl font-bold">验证失败</h1>
<p class="text-muted-foreground text-sm">{{ message }}</p>
<p class="text-muted-foreground text-sm">{{ msg }}</p>
<router-link to="/login">
<Button variant="outline" class="mt-2">返回登录</Button>
</router-link>
+6 -3
View File
@@ -39,9 +39,12 @@ async function onCaptchaConfirm(result: { captchaKey: string; dots: Array<{ x: n
toast.success('重置邮件已发送,请检查邮箱')
captchaOpen.value = false
} catch (err: any) {
toast.error(err?.response?.data?.message || '发送失败')
captchaOpen.value = false
setTimeout(() => { captchaOpen.value = true }, 100)
if (err?.code === 10003) {
captchaOpen.value = false
setTimeout(() => { captchaOpen.value = true }, 100)
} else {
captchaOpen.value = false
}
} finally {
submitting.value = false
}
+16 -1
View File
@@ -1,13 +1,27 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useMouseTracker } from '@/composables/useMouseTracker'
import GeometryCharacters from '@/components/login/GeometryCharacters.vue'
import LoginForm from '@/components/login/LoginForm.vue'
import { authApi } from '@/api/auth'
const { targetX, targetY, setFocusTarget } = useMouseTracker()
const allowRegister = ref(true)
function onFocusTarget(el: HTMLElement | null) {
setFocusTarget(el)
}
onMounted(async () => {
try {
const res = await authApi.getPublicConfig('system.allowRegister')
if (res.code === 200 && res.data) {
allowRegister.value = res.data.value === 'true' || res.data.value === '1'
}
} catch {
allowRegister.value = true
}
})
</script>
<template>
@@ -34,9 +48,10 @@ function onFocusTarget(el: HTMLElement | null) {
<LoginForm @focus-target="onFocusTarget" />
<div class="flex items-center justify-between text-sm">
<router-link to="/register" class="text-primary hover:underline">
<router-link v-if="allowRegister" to="/register" class="text-primary hover:underline">
注册账号
</router-link>
<span v-else></span>
<router-link to="/forgot-password" class="text-muted-foreground hover:text-primary hover:underline">
忘记密码?
</router-link>
+91 -9
View File
@@ -1,5 +1,6 @@
<script setup lang="ts">
import { ref } from 'vue'
import { ref, onMounted, computed } from 'vue'
import { useRouter } from 'vue-router'
import { useForm } from 'vee-validate'
import { toTypedSchema } from '@vee-validate/zod'
import { z } from 'zod'
@@ -10,18 +11,25 @@ import { message as toast } from '@/composables/useMessage'
import { authApi } from '@/api/auth'
import CaptchaDialog from '@/components/login/CaptchaDialog.vue'
const schema = toTypedSchema(
const router = useRouter()
const allowRegister = ref(true)
const requireInviteCode = ref(false)
const checking = ref(true)
const schema = computed(() => toTypedSchema(
z.object({
email: z.string().email('请输入有效邮箱').regex(/^\d+@qq\.com$/i, '仅支持QQ邮箱注册'),
username: z.string().min(2, '用户名至少2个字符').max(20, '用户名最多20个字符'),
password: z.string().min(6, '密码至少6个字符').max(20, '密码最多20个字符'),
confirmPassword: z.string().min(1, '请确认密码'),
inviteCode: z.string().optional(),
inviteCode: requireInviteCode.value
? z.string().min(1, '请输入邀请码')
: z.string().optional(),
}).refine((d) => d.password === d.confirmPassword, {
message: '两次密码不一致',
path: ['confirmPassword'],
})
)
))
const { handleSubmit, errors, defineField } = useForm({ validationSchema: schema })
@@ -33,6 +41,34 @@ const [inviteCode, inviteCodeAttrs] = defineField('inviteCode')
const captchaOpen = ref(false)
const submitting = ref(false)
const registered = ref(false)
const registeredEmail = ref('')
const resending = ref(false)
const resendCaptchaOpen = ref(false)
onMounted(async () => {
try {
const [regRes, invRes] = await Promise.all([
authApi.getPublicConfig('system.allowRegister'),
authApi.getPublicConfig('system.requireInviteCode'),
])
if (regRes.code === 200 && regRes.data) {
allowRegister.value = regRes.data.value === 'true' || regRes.data.value === '1'
}
if (invRes.code === 200 && invRes.data) {
requireInviteCode.value = invRes.data.value === 'true' || invRes.data.value === '1'
}
} catch {
allowRegister.value = true
requireInviteCode.value = false
} finally {
checking.value = false
}
if (!allowRegister.value) {
toast.error('系统当前不允许注册,请联系管理员')
router.push('/login')
}
})
function onSubmitClick() {
handleSubmit(() => {
@@ -53,15 +89,52 @@ async function onCaptchaConfirm(result: { captchaKey: string; dots: Array<{ x: n
dots: result.dots,
})
toast.success('注册成功,请前往邮箱验证')
registeredEmail.value = email.value!
registered.value = true
captchaOpen.value = false
} catch (err: any) {
toast.error(err?.response?.data?.message || '注册失败')
captchaOpen.value = false
setTimeout(() => { captchaOpen.value = true }, 100)
if (err?.code === 10003) {
captchaOpen.value = false
setTimeout(() => { captchaOpen.value = true }, 100)
} else {
captchaOpen.value = false
}
} finally {
submitting.value = false
}
}
function onResendClick() {
const targetEmail = email.value || registeredEmail.value
if (!targetEmail) {
toast.error('请先填写邮箱')
return
}
registeredEmail.value = targetEmail
resendCaptchaOpen.value = true
}
async function onResendCaptchaConfirm(result: { captchaKey: string; dots: Array<{ x: number; y: number }> }) {
resending.value = true
try {
await authApi.resendVerification({
email: registeredEmail.value,
captchaKey: result.captchaKey,
dots: result.dots,
})
toast.success('验证邮件已重新发送,请查收')
resendCaptchaOpen.value = false
} catch (err: any) {
if (err?.code === 10003) {
resendCaptchaOpen.value = false
setTimeout(() => { resendCaptchaOpen.value = true }, 100)
} else {
resendCaptchaOpen.value = false
}
} finally {
resending.value = false
}
}
</script>
<template>
@@ -94,8 +167,9 @@ async function onCaptchaConfirm(result: { captchaKey: string; dots: Array<{ x: n
<p v-if="errors.confirmPassword" class="text-sm text-destructive">{{ errors.confirmPassword }}</p>
</div>
<div class="space-y-2">
<Label for="inviteCode">邀请码(可选)</Label>
<Input id="inviteCode" v-model="inviteCode" v-bind="inviteCodeAttrs" placeholder="如有邀请码请输入" />
<Label for="inviteCode">邀请码{{ requireInviteCode ? '' : '(可选)' }}</Label>
<Input id="inviteCode" v-model="inviteCode" v-bind="inviteCodeAttrs" :placeholder="requireInviteCode ? '请输入邀请码' : '如有邀请码请输入'" />
<p v-if="errors.inviteCode" class="text-sm text-destructive">{{ errors.inviteCode }}</p>
</div>
<Button type="submit" class="w-full" :disabled="submitting">
@@ -108,7 +182,15 @@ async function onCaptchaConfirm(result: { captchaKey: string; dots: Array<{ x: n
<router-link to="/login" class="text-primary hover:underline ml-1">去登录</router-link>
</div>
<div class="text-center text-sm">
<span class="text-muted-foreground">没有收到验证邮件?</span>
<button type="button" class="text-primary hover:underline ml-1" :disabled="resending" @click="onResendClick">
{{ resending ? '发送中...' : '点击这里重新发送' }}
</button>
</div>
<CaptchaDialog v-model:open="captchaOpen" @confirm="onCaptchaConfirm" />
<CaptchaDialog v-model:open="resendCaptchaOpen" @confirm="onResendCaptchaConfirm" />
</div>
</div>
</template>
+28 -11
View File
@@ -7,15 +7,17 @@ import { z } from 'zod'
import { Input } from '@/components/ui/input'
import { Button } from '@/components/ui/button'
import { Label } from '@/components/ui/label'
import { AlertCircle, Loader2 } from 'lucide-vue-next'
import { message as toast } from '@/composables/useMessage'
import { authApi } from '@/api/auth'
const route = useRoute()
const router = useRouter()
const token = ref((route.query.token as string) || '')
const token = ref((route.params.token as string) || '')
const userInfo = ref<{ username?: string; email?: string } | null>(null)
const loadingInfo = ref(false)
const loadingInfo = ref(true)
const errorMessage = ref('')
const schema = toTypedSchema(
z.object({
@@ -34,13 +36,16 @@ const [confirmPassword, confirmPasswordAttrs] = defineField('confirmPassword')
const submitting = ref(false)
onMounted(async () => {
if (!token.value) return
loadingInfo.value = true
if (!token.value) {
errorMessage.value = '缺少重置令牌参数'
loadingInfo.value = false
return
}
try {
const res = await authApi.resetPasswordInfo(token.value)
userInfo.value = res.data
} catch {
toast.error('无效或过期的重置链接')
} catch (err: any) {
errorMessage.value = err?.response?.data?.message || err?.message || '无效或过期的重置链接'
} finally {
loadingInfo.value = false
}
@@ -56,8 +61,8 @@ const onSubmit = handleSubmit(async (values) => {
})
toast.success('密码重置成功,请重新登录')
setTimeout(() => router.push('/login'), 1500)
} catch (err: any) {
toast.error(err?.response?.data?.message || '重置失败')
} catch {
// 全局拦截器已处理错误提示
} finally {
submitting.value = false
}
@@ -72,12 +77,24 @@ const onSubmit = handleSubmit(async (values) => {
<p class="text-muted-foreground text-sm">设置新的登录密码</p>
</div>
<div v-if="loadingInfo" class="text-center text-muted-foreground">加载中...</div>
<!-- 加载中 -->
<div v-if="loadingInfo" class="flex flex-col items-center space-y-4 py-8">
<Loader2 class="h-10 w-10 animate-spin text-muted-foreground" />
<p class="text-sm text-muted-foreground">正在加载账号信息...</p>
</div>
<div v-else-if="!token" class="text-center text-destructive">缺少重置令牌参数</div>
<!-- 错误状态:token 无效或过期 -->
<div v-else-if="errorMessage || !userInfo" class="flex flex-col items-center space-y-4 py-8">
<AlertCircle class="h-12 w-12 text-destructive" />
<p class="text-sm text-muted-foreground">{{ errorMessage || '无效的重置链接' }}</p>
<router-link to="/login">
<Button variant="outline">返回登录</Button>
</router-link>
</div>
<!-- 正常:显示重置表单 -->
<template v-else>
<div v-if="userInfo" class="text-center text-sm text-muted-foreground">
<div class="text-center text-sm text-muted-foreground">
重置用户:<span class="font-medium text-foreground">{{ userInfo.username }}</span>
({{ userInfo.email }})
</div>
+4 -4
View File
@@ -64,7 +64,7 @@ async function fetchData() {
}
aiTypes.value = typesRes.data?.types ?? []
} catch {
toast.error('获取 AI 配置失败')
// 全局拦截器已处理错误提示
} finally {
loading.value = false
}
@@ -80,8 +80,8 @@ const onSave = handleSubmit(async (values) => {
apiKey: values.apiKey,
})
toast.success('AI 配置保存成功')
} catch (err: any) {
toast.error(err.response?.data?.message || '保存失败')
} catch {
// 全局拦截器已处理错误提示
} finally {
saveLoading.value = false
}
@@ -105,7 +105,7 @@ const onTest = handleSubmit(async (values) => {
}
} catch (err: any) {
testResult.value = { success: false, message: err.response?.data?.message || '测试请求失败' }
toast.error(testResult.value.message)
// 全局拦截器已处理错误提示
} finally {
testLoading.value = false
}
+331 -38
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { computed, onMounted, ref } from 'vue'
import { message as toast } from '@/composables/useMessage'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
@@ -8,9 +8,19 @@ import { Switch } from '@/components/ui/switch'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { MdEditor } from 'md-editor-v3'
import 'md-editor-v3/lib/style.css'
import { systemConfigApi, systemPriceApi, type PriceConfig } from '@/api/system'
import { Separator } from '@/components/ui/separator'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { systemConfigApi, systemPriceApi, systemSmtpApi, type PriceConfig, type SmtpConfig } from '@/api/system'
const defaultOAuthScope = 'offline_access https://graph.microsoft.com/Mail.Send'
const defaultOAuthRedirectUrl = 'http://localhost:3200/api/v1/mail/oauth/outlook/callback'
// ---- 公告 ----
const announcementLoading = ref(false)
const announcementSaving = ref(false)
const announcementContent = ref('')
@@ -23,8 +33,6 @@ async function fetchAnnouncement() {
const val = res.data?.value ?? ''
announcementContent.value = val
originalAnnouncement.value = val
} catch (err: any) {
if (err.response?.status !== 404) toast.error('获取公告失败')
} finally {
announcementLoading.value = false
}
@@ -41,10 +49,8 @@ async function saveAnnouncement() {
value: announcementContent.value,
description: '系统公告',
})
toast.success('公告保存成功')
originalAnnouncement.value = announcementContent.value
} catch {
toast.error('保存失败')
toast.success('公告保存成功')
} finally {
announcementSaving.value = false
}
@@ -55,19 +61,23 @@ function resetAnnouncement() {
toast.info('已重置')
}
// ---- 注册开关 ----
const registerLoading = ref(false)
const registerSaving = ref(false)
const allowRegister = ref(true)
const requireInviteCode = ref(false)
async function fetchRegisterConfig() {
registerLoading.value = true
try {
const res = await systemConfigApi.get('system.allowRegister')
const val = res.data?.value
allowRegister.value = val === 'true' || val === '1'
const [regRes, invRes] = await Promise.all([
systemConfigApi.get('system.allowRegister'),
systemConfigApi.get('system.requireInviteCode').catch(() => null),
])
allowRegister.value = ['true', '1'].includes(regRes.data?.value ?? '')
requireInviteCode.value = ['true', '1'].includes(invRes?.data?.value ?? '')
} catch {
allowRegister.value = true
requireInviteCode.value = false
} finally {
registerLoading.value = false
}
@@ -76,19 +86,22 @@ async function fetchRegisterConfig() {
async function saveRegisterConfig() {
registerSaving.value = true
try {
await systemConfigApi.update('system.allowRegister', {
value: allowRegister.value ? 'true' : 'false',
description: '是否允许用户注册',
})
toast.success('注册开关保存成功')
} catch {
toast.error('保存失败')
await Promise.all([
systemConfigApi.update('system.allowRegister', {
value: allowRegister.value ? 'true' : 'false',
description: '是否允许用户注册',
}),
systemConfigApi.update('system.requireInviteCode', {
value: requireInviteCode.value ? 'true' : 'false',
description: '注册是否必须邀请码',
}),
])
toast.success('注册配置保存成功')
} finally {
registerSaving.value = false
}
}
// ---- 费率配置 ----
const priceLoading = ref(false)
const priceSaving = ref(false)
const priceConfig = ref<PriceConfig>({
@@ -106,8 +119,6 @@ async function fetchPriceConfig() {
if (res.data) {
priceConfig.value = { ...priceConfig.value, ...res.data }
}
} catch {
// 使用默认值
} finally {
priceLoading.value = false
}
@@ -118,17 +129,193 @@ async function savePriceConfig() {
try {
await systemPriceApi.update(priceConfig.value)
toast.success('费率配置保存成功')
} catch {
toast.error('保存失败')
} finally {
priceSaving.value = false
}
}
const smtpLoading = ref(false)
const smtpSaving = ref(false)
const smtpTesting = ref(false)
const oauthAuthorizing = ref(false)
const testEmail = ref('')
const smtpConfig = ref<SmtpConfig>({
authMode: 'smtp',
smtpHost: '',
smtpPort: 587,
smtpUsername: '',
smtpPassword: '',
fromEmail: '',
fromName: '',
oauthTenant: 'consumers',
oauthClientId: '',
oauthClientSecret: '',
oauthRedirectUrl: '',
oauthRefreshToken: '',
oauthScope: defaultOAuthScope,
})
const isOAuthMode = computed(() => smtpConfig.value.authMode === 'outlook_oauth2')
function resolveApiOrigin() {
const apiBaseUrl = import.meta.env.VITE_API_BASE_URL || '/api'
return new URL(apiBaseUrl, window.location.origin).origin
}
function applyModeDefaults(mode: SmtpConfig['authMode']) {
if (mode === 'outlook_oauth2') {
smtpConfig.value.oauthTenant = 'consumers'
smtpConfig.value.oauthScope = defaultOAuthScope
smtpConfig.value.oauthRedirectUrl = defaultOAuthRedirectUrl
}
}
async function fetchSmtpConfig() {
smtpLoading.value = true
try {
const res = await systemSmtpApi.get()
if (res.data) {
smtpConfig.value = { ...smtpConfig.value, ...res.data }
smtpConfig.value.smtpPort = Number(smtpConfig.value.smtpPort) || 587
applyModeDefaults(smtpConfig.value.authMode)
}
} finally {
smtpLoading.value = false
}
}
function buildSmtpPayload(): SmtpConfig {
const payload: SmtpConfig = { ...smtpConfig.value }
if (payload.authMode === 'outlook_oauth2') {
payload.oauthTenant = 'consumers'
payload.oauthScope = defaultOAuthScope
payload.oauthRedirectUrl = defaultOAuthRedirectUrl
}
return payload
}
async function saveSmtpConfig() {
if (!smtpConfig.value.fromEmail || !smtpConfig.value.fromName) {
toast.error('请先填写发件人信息')
return
}
if (isOAuthMode.value) {
if (!smtpConfig.value.oauthClientId || !smtpConfig.value.oauthClientSecret || !smtpConfig.value.oauthRefreshToken) {
toast.error('Outlook OAuth2 模式下请先完成授权或手动填写 Refresh Token')
return
}
} else {
if (!smtpConfig.value.smtpHost || !smtpConfig.value.smtpUsername || !smtpConfig.value.smtpPassword) {
toast.error('SMTP 模式下请填写服务器、账号和密码')
return
}
}
smtpSaving.value = true
try {
await systemSmtpApi.update(buildSmtpPayload())
toast.success('邮件配置保存成功')
} finally {
smtpSaving.value = false
}
}
async function startOutlookAuthorize() {
if (!smtpConfig.value.fromEmail) {
toast.error('请先填写发件人邮箱')
return
}
if (!smtpConfig.value.oauthClientId || !smtpConfig.value.oauthClientSecret) {
toast.error('请先填写 Client ID 和 Client Secret')
return
}
oauthAuthorizing.value = true
try {
const res = await systemSmtpApi.getOutlookAuthorizeUrl({
oauthTenant: 'consumers',
oauthClientId: smtpConfig.value.oauthClientId,
oauthClientSecret: smtpConfig.value.oauthClientSecret,
oauthScope: defaultOAuthScope,
fromEmail: smtpConfig.value.fromEmail,
frontendOrigin: window.location.origin,
callbackBaseUrl: defaultOAuthRedirectUrl,
})
const authUrl = res.data?.authUrl
const callbackUrl = res.data?.callbackUrl
if (!authUrl || !callbackUrl) {
toast.error('未能生成授权地址')
return
}
smtpConfig.value.oauthRedirectUrl = callbackUrl
const popup = window.open(authUrl, 'outlook-oauth2', 'width=720,height=820')
if (!popup) {
toast.error('浏览器拦截了弹窗,请允许后重试')
return
}
const result = await new Promise<{ success: boolean; message?: string; refreshToken?: string }>((resolve, reject) => {
const timer = window.setInterval(() => {
if (popup.closed) {
window.clearInterval(timer)
window.removeEventListener('message', onMessage)
reject(new Error('授权窗口已关闭'))
}
}, 500)
function onMessage(event: MessageEvent) {
const data = event.data
if (event.origin !== resolveApiOrigin() || !data || data.source !== 'likedream-outlook-oauth') {
return
}
window.clearInterval(timer)
window.removeEventListener('message', onMessage)
resolve(data)
}
window.addEventListener('message', onMessage)
})
if (!result.success) {
toast.error(result.message || '授权失败')
return
}
if (result.refreshToken) {
smtpConfig.value.oauthRefreshToken = result.refreshToken
}
await fetchSmtpConfig()
smtpConfig.value.oauthRedirectUrl = defaultOAuthRedirectUrl
toast.success('Outlook 授权成功,Refresh Token 已保存并刷新显示')
} catch (error: any) {
toast.error(error?.message || '获取 Outlook 授权失败')
} finally {
oauthAuthorizing.value = false
}
}
async function testSmtp() {
if (!testEmail.value.trim()) {
toast.error('请输入测试收件邮箱')
return
}
smtpTesting.value = true
try {
await systemSmtpApi.test(testEmail.value.trim())
toast.success('测试邮件发送成功')
} finally {
smtpTesting.value = false
}
}
onMounted(() => {
fetchAnnouncement()
fetchRegisterConfig()
fetchPriceConfig()
fetchSmtpConfig()
})
</script>
@@ -136,21 +323,15 @@ onMounted(() => {
<div class="space-y-6">
<h2 class="text-2xl font-bold tracking-tight">系统配置</h2>
<!-- 公告编辑 -->
<Card>
<CardHeader>
<CardTitle class="text-base">系统公告</CardTitle>
</CardHeader>
<CardContent class="space-y-3">
<div v-if="announcementLoading" class="text-sm text-muted-foreground py-4 text-center">加载中...</div>
<div v-if="announcementLoading" class="py-4 text-center text-sm text-muted-foreground">加载中...</div>
<template v-else>
<MdEditor
v-model="announcementContent"
language="zh-CN"
:preview="true"
style="height: 500px"
/>
<div class="flex justify-end gap-2 mt-3">
<MdEditor v-model="announcementContent" language="zh-CN" :preview="true" style="height: 500px" />
<div class="mt-3 flex justify-end gap-2">
<Button variant="outline" size="sm" @click="resetAnnouncement">重置</Button>
<Button size="sm" :disabled="announcementSaving" @click="saveAnnouncement">
{{ announcementSaving ? '保存中...' : '保存公告' }}
@@ -160,20 +341,24 @@ onMounted(() => {
</CardContent>
</Card>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<!-- 注册开关 -->
<div class="grid grid-cols-1 gap-6 lg:grid-cols-2">
<Card>
<CardHeader>
<CardTitle class="text-base">注册开关配置</CardTitle>
</CardHeader>
<CardContent class="space-y-4">
<div v-if="registerLoading" class="text-sm text-muted-foreground py-4 text-center">加载中...</div>
<div v-if="registerLoading" class="py-4 text-center text-sm text-muted-foreground">加载中...</div>
<template v-else>
<div class="flex items-center gap-3">
<Label>允许用户注册</Label>
<Switch v-model="allowRegister" />
<span class="text-sm text-muted-foreground">{{ allowRegister ? '已开启' : '已关闭' }}</span>
</div>
<div class="flex items-center gap-3">
<Label>注册必须邀请码</Label>
<Switch v-model="requireInviteCode" />
<span class="text-sm text-muted-foreground">{{ requireInviteCode ? '已开启' : '已关闭' }}</span>
</div>
<div class="flex justify-end">
<Button size="sm" :disabled="registerSaving" @click="saveRegisterConfig">
{{ registerSaving ? '保存中...' : '保存配置' }}
@@ -183,13 +368,12 @@ onMounted(() => {
</CardContent>
</Card>
<!-- 费率配置 -->
<Card>
<CardHeader>
<CardTitle class="text-base">费率配置</CardTitle>
</CardHeader>
<CardContent class="space-y-4">
<div v-if="priceLoading" class="text-sm text-muted-foreground py-4 text-center">加载中...</div>
<div v-if="priceLoading" class="py-4 text-center text-sm text-muted-foreground">加载中...</div>
<template v-else>
<div class="space-y-3">
<div class="flex items-center gap-3">
@@ -227,5 +411,114 @@ onMounted(() => {
</CardContent>
</Card>
</div>
<Card>
<CardHeader>
<CardTitle class="text-base">邮件配置</CardTitle>
</CardHeader>
<CardContent class="space-y-4">
<div v-if="smtpLoading" class="py-4 text-center text-sm text-muted-foreground">加载中...</div>
<template v-else>
<div class="grid max-w-3xl grid-cols-1 gap-4 md:grid-cols-2">
<div class="space-y-2">
<Label>发信模式</Label>
<Select
:model-value="smtpConfig.authMode"
@update:model-value="(value) => { smtpConfig.authMode = value as SmtpConfig['authMode']; applyModeDefaults(smtpConfig.authMode) }"
>
<SelectTrigger>
<SelectValue placeholder="选择邮件发信模式" />
</SelectTrigger>
<SelectContent>
<SelectItem value="smtp">SMTP 密码 / 授权码</SelectItem>
<SelectItem value="outlook_oauth2">Outlook OAuth2</SelectItem>
</SelectContent>
</Select>
</div>
<div class="space-y-2">
<Label>发件人邮箱</Label>
<Input v-model="smtpConfig.fromEmail" placeholder="例如 you@outlook.com" />
</div>
<div class="space-y-2">
<Label>发件人名称</Label>
<Input v-model="smtpConfig.fromName" placeholder="邮件中显示的发件人名称" />
</div>
</div>
<div v-if="!isOAuthMode" class="grid max-w-3xl grid-cols-1 gap-4 md:grid-cols-2">
<div class="space-y-2">
<Label>SMTP 服务器</Label>
<Input v-model="smtpConfig.smtpHost" placeholder="例如 smtp.qq.com" />
</div>
<div class="space-y-2">
<Label>SMTP 端口</Label>
<Input v-model.number="smtpConfig.smtpPort" type="number" placeholder="例如 587" />
</div>
<div class="space-y-2">
<Label>SMTP 登录账号</Label>
<Input v-model="smtpConfig.smtpUsername" placeholder="SMTP 登录账号" />
</div>
<div class="space-y-2">
<Label>SMTP 密码 / 授权码</Label>
<Input v-model="smtpConfig.smtpPassword" type="password" placeholder="SMTP 登录密码或授权码" />
</div>
</div>
<div v-else class="space-y-4">
<div class="rounded-lg border border-amber-200 bg-amber-50 p-4 text-sm text-amber-900">
<p class="font-medium">Outlook OAuth2 + Microsoft Graph 模式</p>
<p class="mt-1">
OAuth2 模式下系统不会再走 SMTP,而是直接调用 <code>Microsoft Graph /me/sendMail</code>,
因此不会再显示 SMTP 服务器、端口和账号输入项,邮件也会以当前完成授权的微软账号身份发送。
</p>
</div>
<div class="grid max-w-3xl grid-cols-1 gap-4 md:grid-cols-2">
<div class="space-y-2">
<Label>Client ID</Label>
<Input v-model="smtpConfig.oauthClientId" placeholder="Microsoft Entra 应用的 Client ID" />
</div>
<div class="space-y-2">
<Label>Client Secret</Label>
<Input v-model="smtpConfig.oauthClientSecret" type="password" placeholder="应用机密" />
</div>
<div class="space-y-2 md:col-span-2">
<Label>回调地址</Label>
<Input v-model="smtpConfig.oauthRedirectUrl" readonly placeholder="点击下方授权按钮后自动生成" />
</div>
<div class="space-y-2 md:col-span-2">
<Label>Refresh Token</Label>
<Input v-model="smtpConfig.oauthRefreshToken" type="password" placeholder="点击下方按钮自动回填,或手动粘贴" />
</div>
</div>
<div class="flex max-w-3xl items-center gap-3">
<Button variant="outline" :disabled="oauthAuthorizing" @click="startOutlookAuthorize">
{{ oauthAuthorizing ? '授权中...' : '一键授权并回填 Refresh Token' }}
</Button>
<span class="text-sm text-muted-foreground">会弹出微软登录窗口,固定使用 <code>consumers</code> 与 <code>Mail.Send</code> 授权并在完成后自动回填。</span>
</div>
</div>
<Separator />
<div class="flex max-w-3xl items-end gap-3">
<div class="flex-1 space-y-2">
<Label>测试收件邮箱</Label>
<Input v-model="testEmail" placeholder="输入一个邮箱地址发送测试邮件" />
</div>
<Button variant="outline" size="sm" :disabled="smtpTesting" @click="testSmtp">
{{ smtpTesting ? '发送中...' : '发送测试' }}
</Button>
</div>
<div class="flex max-w-3xl justify-end">
<Button size="sm" :disabled="smtpSaving" @click="saveSmtpConfig">
{{ smtpSaving ? '保存中...' : '保存配置' }}
</Button>
</div>
</template>
</CardContent>
</Card>
</div>
</template>
+7 -7
View File
@@ -62,7 +62,7 @@ async function fetchMenuTree() {
updateChildMenuList()
}
} catch {
toast.error('获取菜单树失败')
// 全局拦截器已处理错误提示
} finally {
loading.value = false
}
@@ -199,8 +199,8 @@ function handleDelete(id: number, name: string) {
toast.success('删除成功')
if (selectedMenu.value?.id === id) selectedMenu.value = null
fetchMenuTree()
} catch (err: any) {
toast.error(err.response?.data?.message || '删除失败')
} catch {
// 全局拦截器已处理错误提示
}
})
}
@@ -298,8 +298,8 @@ const onMenuSubmit = handleMenuSubmit(async (values) => {
fetchMenuTree()
// 刷新按钮列表
if (selectedMenu.value) loadButtons(selectedMenu.value.id)
} catch (err: any) {
toast.error(err.response?.data?.message || '操作失败')
} catch {
// 全局拦截器已处理错误提示
} finally {
editLoading.value = false
}
@@ -313,8 +313,8 @@ function handleDeleteBtn(id: number, name: string) {
toast.success('删除成功')
if (selectedMenu.value) loadButtons(selectedMenu.value.id)
fetchMenuTree()
} catch (err: any) {
toast.error(err.response?.data?.message || '删除失败')
} catch {
// 全局拦截器已处理错误提示
}
})
}
+3 -3
View File
@@ -52,7 +52,7 @@ async function fetchPrice() {
})
}
} catch {
toast.error('获取费率配置失败')
// 全局拦截器已处理错误提示
} finally {
loading.value = false
}
@@ -63,8 +63,8 @@ const onSave = handleSubmit(async (values) => {
try {
await systemPriceApi.update(values)
toast.success('费率配置保存成功')
} catch (err: any) {
toast.error(err.response?.data?.message || '保存失败')
} catch {
// 全局拦截器已处理错误提示
} finally {
saveLoading.value = false
}
+7 -7
View File
@@ -104,8 +104,8 @@ const onRoleSubmit = handleRoleSubmit(async (values) => {
}
editDialogOpen.value = false
tableRef.value?.refresh()
} catch (err: any) {
toast.error(err.response?.data?.message || '操作失败')
} catch {
// 全局拦截器已处理错误提示
} finally {
editLoading.value = false
}
@@ -118,8 +118,8 @@ function handleDelete(id: number) {
await systemRoleApi.delete(id)
toast.success('删除成功')
tableRef.value?.refresh()
} catch (err: any) {
toast.error(err.response?.data?.message || '删除失败')
} catch {
// 全局拦截器已处理错误提示
}
})
}
@@ -157,7 +157,7 @@ async function openMenuAuth(roleId: number) {
const ids = roleMenuRes.data
selectedMenuIds.value = Array.isArray(ids) ? ids : []
} catch {
toast.error('获取菜单数据失败')
// 全局拦截器已处理错误提示
} finally {
menuLoading.value = false
}
@@ -178,8 +178,8 @@ async function submitMenuAuth() {
await systemRoleApi.authorizeMenu(menuRoleId.value, selectedMenuIds.value)
toast.success('菜单授权成功')
menuDialogOpen.value = false
} catch (err: any) {
toast.error(err.response?.data?.message || '菜单授权失败')
} catch {
// 全局拦截器已处理错误提示
} finally {
menuLoading.value = false
}
+10 -10
View File
@@ -81,8 +81,8 @@ const onResetPwdSubmit = handleResetPwdSubmit(async (values) => {
await systemUserApi.resetPassword(resetPwdUserId.value, values.password)
toast.success('密码重置成功')
resetPwdOpen.value = false
} catch (err: any) {
toast.error(err.response?.data?.message || '密码重置失败')
} catch {
// 全局拦截器已处理错误提示
} finally {
resetPwdLoading.value = false
}
@@ -105,8 +105,8 @@ async function openRoleDialog(userId: number, currentRoles?: any[]) {
roleList.value = Array.isArray(data) ? data : (data as any)?.pageData ?? []
// 预选用户当前角色
selectedRoleIds.value = currentRoles?.map((r: any) => r.id) ?? []
} catch (err: any) {
toast.error('获取角色列表失败')
} catch {
// 全局拦截器已处理错误提示
} finally {
roleLoading.value = false
}
@@ -128,8 +128,8 @@ async function submitRoleAuth() {
toast.success('角色授权成功')
roleDialogOpen.value = false
tableRef.value?.refresh()
} catch (err: any) {
toast.error(err.response?.data?.message || '角色授权失败')
} catch {
// 全局拦截器已处理错误提示
} finally {
roleLoading.value = false
}
@@ -144,8 +144,8 @@ async function handleBlock(id: number, currentBlock: boolean) {
await systemUserApi.blockUser(id, willBlock)
toast.success(`${action}成功`)
tableRef.value?.refresh()
} catch (err: any) {
toast.error(err.response?.data?.message || `${action}失败`)
} catch {
// 全局拦截器已处理错误提示
}
})
}
@@ -156,8 +156,8 @@ async function handleDelete(id: number) {
await systemUserApi.deleteUser(id)
toast.success('删除成功')
tableRef.value?.refresh()
} catch (err: any) {
toast.error(err.response?.data?.message || '删除失败')
} catch {
// 全局拦截器已处理错误提示
}
})
}
+30 -6
View File
@@ -12,12 +12,13 @@ import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { User, Lock, CreditCard, Copy, Users } from 'lucide-vue-next'
import { User, Lock, CreditCard, Copy, Users, RefreshCw } from 'lucide-vue-next'
const userStore = useUserStore()
const passwordLoading = ref(false)
const rechargeLoading = ref(false)
const cardCode = ref('')
const generateLoading = ref(false)
// 修改密码表单
const passwordSchema = toTypedSchema(
@@ -50,8 +51,8 @@ const onPasswordSubmit = handlePasswordSubmit(async (values) => {
await userApi.resetPassword(values)
toast.success('密码修改成功')
resetPasswordForm()
} catch (err: any) {
toast.error(err.response?.data?.message || '密码修改失败')
} catch {
// 全局拦截器已处理错误提示
} finally {
passwordLoading.value = false
}
@@ -69,8 +70,8 @@ async function handleRecharge() {
toast.success('充值成功')
cardCode.value = ''
userStore.fetchProfile()
} catch (err: any) {
toast.error(err.response?.data?.message || '充值失败')
} catch {
// 全局拦截器已处理错误提示
} finally {
rechargeLoading.value = false
}
@@ -88,6 +89,20 @@ async function copyInviteCode() {
}
}
// 生成邀请码
async function handleGenerateInviteCode() {
generateLoading.value = true
try {
await userApi.generateInviteCode()
toast.success('邀请码生成成功')
userStore.fetchProfile()
} catch {
// 全局拦截器已处理错误提示
} finally {
generateLoading.value = false
}
}
onMounted(() => {
if (!userStore.profile) {
userStore.fetchProfile()
@@ -247,8 +262,17 @@ onMounted(() => {
<div class="space-y-4 max-w-md">
<div class="flex items-center gap-3">
<code class="flex-1 rounded-md border bg-muted px-4 py-2.5 text-sm font-mono">
{{ userStore.profile?.invite_code ?? '-' }}
{{ userStore.profile?.invite_code ?? '暂无邀请码' }}
</code>
<Button
variant="outline"
size="sm"
:disabled="generateLoading"
@click="handleGenerateInviteCode"
>
<RefreshCw class="mr-1.5 h-3.5 w-3.5" :class="{ 'animate-spin': generateLoading }" />
生成
</Button>
<Button
variant="outline"
size="sm"
+42 -1
View File
@@ -2,9 +2,50 @@ import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import tailwindcss from '@tailwindcss/vite'
import { fileURLToPath, URL } from 'node:url'
import vitePluginBundleObfuscator from 'vite-plugin-bundle-obfuscator'
export default defineConfig({
plugins: [vue(), tailwindcss()],
plugins: [
vue(),
tailwindcss(),
vitePluginBundleObfuscator({
apply: 'build',
log: true,
autoExcludeNodeModules: true,
threadPool: true,
excludes: [
/assets\/rolldown-runtime-.*\.js$/,
],
options: {
compact: true,
controlFlowFlattening: false,
deadCodeInjection: false,
debugProtection: false,
debugProtectionInterval: 0,
disableConsoleOutput: true,
identifierNamesGenerator: 'hexadecimal',
ignoreImports: true,
numbersToExpressions: false,
renameGlobals: false,
selfDefending: false,
simplify: true,
splitStrings: false,
stringArray: true,
stringArrayCallsTransform: false,
stringArrayEncoding: [],
stringArrayIndexShift: true,
stringArrayRotate: true,
stringArrayShuffle: true,
stringArrayWrappersChainedCalls: true,
stringArrayWrappersCount: 1,
stringArrayWrappersParametersMaxCount: 2,
stringArrayWrappersType: 'variable',
stringArrayThreshold: 0.75,
transformObjectKeys: false,
unicodeEscapeSequence: false,
},
}),
],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url))