Compare commits
26 Commits
v0.0.1.beta9
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 6ec4118932 | |||
| 70fd865e39 | |||
| f77d71a96e | |||
| 561c756b26 | |||
| 75c861629f | |||
| 18fcae8030 | |||
| 564dbd436d | |||
| 7ce6ca34ca | |||
| 94c5532b82 | |||
| dd6c308996 | |||
| ebf02eb58f | |||
| 5baa91e3e7 | |||
| 08c47cfa03 | |||
| cb5440c8d9 | |||
| 05eaa4741a | |||
| c0d5b4697f | |||
| d742425cac | |||
| 5729e88687 | |||
| 0681aad27d | |||
| ed38bfd463 | |||
| 1c38eccfd3 | |||
| ac6b0fa81a | |||
| 2603aada26 | |||
| 223ddf4f66 | |||
| 1f58572b8f | |||
| a8125cc224 |
@@ -6,7 +6,7 @@ on: [release]
|
||||
|
||||
env:
|
||||
REPO: sugarbecomer/tianshen-web
|
||||
REGISTRY: git.likedream.xyz
|
||||
REGISTRY: mycoder.cc.cd
|
||||
|
||||
jobs:
|
||||
Build-And-Upload:
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
when:
|
||||
- event: push
|
||||
branch: [main, master]
|
||||
- event: tag
|
||||
branch: [v*]
|
||||
|
||||
steps:
|
||||
install-deps:
|
||||
image: node:22-alpine
|
||||
commands:
|
||||
- npm ci
|
||||
|
||||
build-frontend:
|
||||
image: node:22-alpine
|
||||
commands:
|
||||
- npm run build
|
||||
depends_on:
|
||||
- install-deps
|
||||
|
||||
build-image:
|
||||
image: docker
|
||||
commands:
|
||||
- docker build -t likedream-web:latest .
|
||||
- docker tag likedream-web:latest likedream-web:${CI_COMMIT_TAG:-${CI_COMMIT_SHA:0:8}}
|
||||
depends_on:
|
||||
- build-frontend
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
|
||||
deploy:
|
||||
image: docker
|
||||
commands: |
|
||||
# 确保网络存在
|
||||
docker network create likedream-net 2>/dev/null || true
|
||||
|
||||
# 停止并删除旧容器
|
||||
docker stop likedream-web 2>/dev/null || true
|
||||
docker rm likedream-web 2>/dev/null || true
|
||||
|
||||
# 启动新容器
|
||||
docker run -d \
|
||||
--name likedream-web \
|
||||
--restart unless-stopped \
|
||||
--network likedream-net \
|
||||
-p 3080:3000 \
|
||||
-e BACKEND_URL=http://likedream-go:8091 \
|
||||
likedream-web:latest
|
||||
depends_on:
|
||||
- build-image
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
@@ -0,0 +1,15 @@
|
||||
services:
|
||||
likedream-web:
|
||||
image: likedream-web:latest
|
||||
container_name: likedream-web
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "3000:3000"
|
||||
environment:
|
||||
- BACKEND_URL=http://likedream-go:8091
|
||||
networks:
|
||||
- likedream-net
|
||||
|
||||
networks:
|
||||
likedream-net:
|
||||
external: true
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Generated
+1094
File diff suppressed because it is too large
Load Diff
@@ -39,6 +39,7 @@
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ import type {
|
||||
CpTaskExecuteParams,
|
||||
CpTaskFormItem,
|
||||
CpLeaveReportParams,
|
||||
CpLeaveApplyParams,
|
||||
TaskStatisticsItem,
|
||||
TaskStatisticsV2Item,
|
||||
DashboardSummary,
|
||||
@@ -75,6 +76,8 @@ export const cpTaskApi = {
|
||||
request.post<any, ApiResponse>(`/v1/cpdaily/task/execute/${id}`, data),
|
||||
executeNow: (id: number) =>
|
||||
request.post<any, ApiResponse>(`/v1/cpdaily/task/executeNow/${id}`),
|
||||
updateTitle: (id: number, title: string) =>
|
||||
request.put<any, ApiResponse>(`/v1/cpdaily/task/title/${id}`, { title }),
|
||||
pause: (id: number) =>
|
||||
request.post<any, ApiResponse>(`/v1/cpdaily/task/pause/${id}`),
|
||||
findLocation: (address: string) =>
|
||||
@@ -117,4 +120,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),
|
||||
}
|
||||
|
||||
+12
-1
@@ -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 自动刷新 ----
|
||||
@@ -70,6 +70,17 @@ request.interceptors.response.use(
|
||||
// 业务错误:HTTP 200 但 code 不是 200
|
||||
if (data && typeof data.code === 'number' && data.code !== 200) {
|
||||
const msg = data.message || '请求失败'
|
||||
// JWT 过期 / 未授权:清 token 跳登录
|
||||
if (data.code === 401 || data.code === 403) {
|
||||
localStorage.removeItem('token')
|
||||
message.error(msg)
|
||||
import('@/router').then(({ default: router }) => {
|
||||
router.push('/login')
|
||||
})
|
||||
const err: any = new Error(msg)
|
||||
err.code = data.code
|
||||
return Promise.reject(err)
|
||||
}
|
||||
message.error(msg)
|
||||
const err: any = new Error(msg)
|
||||
err.code = data.code
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
|
||||
@@ -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'),
|
||||
}
|
||||
|
||||
+109
-87
@@ -5,58 +5,58 @@
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--background: 0 0% 100%;
|
||||
--foreground: 240 10% 3.9%;
|
||||
--background: 220 60% 99%;
|
||||
--foreground: 220 30% 12%;
|
||||
--card: 0 0% 100%;
|
||||
--card-foreground: 240 10% 3.9%;
|
||||
--card-foreground: 220 30% 12%;
|
||||
--popover: 0 0% 100%;
|
||||
--popover-foreground: 240 10% 3.9%;
|
||||
--primary: 240 5.9% 10%;
|
||||
--popover-foreground: 220 30% 12%;
|
||||
--primary: 228 60% 62%;
|
||||
--primary-foreground: 0 0% 98%;
|
||||
--secondary: 240 4.8% 95.9%;
|
||||
--secondary-foreground: 240 5.9% 10%;
|
||||
--muted: 240 4.8% 95.9%;
|
||||
--muted-foreground: 240 3.8% 46.1%;
|
||||
--accent: 240 4.8% 95.9%;
|
||||
--accent-foreground: 240 5.9% 10%;
|
||||
--destructive: 0 84.2% 60.2%;
|
||||
--secondary: 220 20% 96%;
|
||||
--secondary-foreground: 220 30% 18%;
|
||||
--muted: 220 15% 96%;
|
||||
--muted-foreground: 220 10% 42%;
|
||||
--accent: 220 30% 95%;
|
||||
--accent-foreground: 220 30% 18%;
|
||||
--destructive: 15 65% 55%;
|
||||
--destructive-foreground: 0 0% 98%;
|
||||
--border: 240 5.9% 90%;
|
||||
--input: 240 5.9% 90%;
|
||||
--ring: 240 5.9% 10%;
|
||||
--radius: 0.5rem;
|
||||
--chart-1: 12 76% 61%;
|
||||
--chart-2: 173 58% 39%;
|
||||
--chart-3: 197 37% 24%;
|
||||
--chart-4: 43 74% 66%;
|
||||
--chart-5: 27 87% 67%;
|
||||
--border: 220 15% 92%;
|
||||
--input: 220 15% 92%;
|
||||
--ring: 228 60% 62%;
|
||||
--radius: 0.75rem;
|
||||
--chart-1: 228 60% 62%;
|
||||
--chart-2: 155 40% 50%;
|
||||
--chart-3: 40 50% 55%;
|
||||
--chart-4: 280 40% 58%;
|
||||
--chart-5: 15 50% 55%;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: 240 10% 3.9%;
|
||||
--background: 220 20% 8%;
|
||||
--foreground: 0 0% 98%;
|
||||
--card: 240 10% 3.9%;
|
||||
--card: 220 20% 12%;
|
||||
--card-foreground: 0 0% 98%;
|
||||
--popover: 240 10% 3.9%;
|
||||
--popover: 220 20% 12%;
|
||||
--popover-foreground: 0 0% 98%;
|
||||
--primary: 0 0% 98%;
|
||||
--primary-foreground: 240 5.9% 10%;
|
||||
--secondary: 240 3.7% 15.9%;
|
||||
--primary: 228 55% 68%;
|
||||
--primary-foreground: 220 30% 10%;
|
||||
--secondary: 220 15% 18%;
|
||||
--secondary-foreground: 0 0% 98%;
|
||||
--muted: 240 3.7% 15.9%;
|
||||
--muted-foreground: 240 5% 64.9%;
|
||||
--accent: 240 3.7% 15.9%;
|
||||
--muted: 220 15% 18%;
|
||||
--muted-foreground: 220 10% 60%;
|
||||
--accent: 220 18% 18%;
|
||||
--accent-foreground: 0 0% 98%;
|
||||
--destructive: 0 62.8% 30.6%;
|
||||
--destructive: 15 60% 55%;
|
||||
--destructive-foreground: 0 0% 98%;
|
||||
--border: 240 3.7% 15.9%;
|
||||
--input: 240 3.7% 15.9%;
|
||||
--ring: 240 4.9% 83.9%;
|
||||
--chart-1: 220 70% 50%;
|
||||
--chart-2: 160 60% 45%;
|
||||
--chart-3: 30 80% 55%;
|
||||
--chart-4: 280 65% 60%;
|
||||
--chart-5: 340 75% 55%;
|
||||
--border: 220 15% 18%;
|
||||
--input: 220 15% 18%;
|
||||
--ring: 228 55% 68%;
|
||||
--chart-1: 228 55% 68%;
|
||||
--chart-2: 155 40% 52%;
|
||||
--chart-3: 40 55% 58%;
|
||||
--chart-4: 280 45% 60%;
|
||||
--chart-5: 15 55% 58%;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,6 +89,12 @@
|
||||
--color-accent: var(--accent);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-success: var(--success);
|
||||
--color-success-foreground: var(--success-foreground);
|
||||
--color-warning: var(--warning);
|
||||
--color-warning-foreground: var(--warning-foreground);
|
||||
--color-info: var(--info);
|
||||
--color-info-foreground: var(--info-foreground);
|
||||
--color-border: var(--border);
|
||||
--color-input: var(--input);
|
||||
--color-ring: var(--ring);
|
||||
@@ -108,72 +114,88 @@
|
||||
}
|
||||
|
||||
:root {
|
||||
--radius: 0.625rem;
|
||||
--background: oklch(1 0 0);
|
||||
--foreground: oklch(0.145 0 0);
|
||||
--radius: 0.75rem;
|
||||
--background: oklch(0.995 0.002 250);
|
||||
--foreground: oklch(0.18 0.02 250);
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.145 0 0);
|
||||
--card-foreground: oklch(0.18 0.02 250);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.145 0 0);
|
||||
--primary: oklch(0.205 0 0);
|
||||
--popover-foreground: oklch(0.18 0.02 250);
|
||||
--primary: oklch(0.62 0.12 260);
|
||||
--primary-foreground: oklch(0.985 0 0);
|
||||
--secondary: oklch(0.97 0 0);
|
||||
--secondary-foreground: oklch(0.205 0 0);
|
||||
--muted: oklch(0.97 0 0);
|
||||
--muted-foreground: oklch(0.556 0 0);
|
||||
--accent: oklch(0.97 0 0);
|
||||
--accent-foreground: oklch(0.205 0 0);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--border: oklch(0.922 0 0);
|
||||
--input: oklch(0.922 0 0);
|
||||
--ring: oklch(0.708 0 0);
|
||||
--chart-1: oklch(0.646 0.222 41.116);
|
||||
--chart-2: oklch(0.6 0.118 184.704);
|
||||
--chart-3: oklch(0.398 0.07 227.392);
|
||||
--chart-4: oklch(0.828 0.189 84.429);
|
||||
--chart-5: oklch(0.769 0.188 70.08);
|
||||
--sidebar: oklch(0.985 0 0);
|
||||
--sidebar-foreground: oklch(0.145 0 0);
|
||||
--sidebar-primary: oklch(0.205 0 0);
|
||||
--secondary: oklch(0.965 0.008 250);
|
||||
--secondary-foreground: oklch(0.25 0.04 250);
|
||||
--muted: oklch(0.965 0.006 250);
|
||||
--muted-foreground: oklch(0.52 0.02 250);
|
||||
--accent: oklch(0.955 0.018 250);
|
||||
--accent-foreground: oklch(0.25 0.04 250);
|
||||
--destructive: oklch(0.70 0.14 25);
|
||||
--border: oklch(0.925 0.008 250);
|
||||
--input: oklch(0.925 0.008 250);
|
||||
--ring: oklch(0.62 0.12 260);
|
||||
--success: oklch(0.65 0.12 155);
|
||||
--success-foreground: oklch(0.985 0 0);
|
||||
--warning: oklch(0.75 0.12 80);
|
||||
--warning-foreground: oklch(0.25 0.04 60);
|
||||
--info: oklch(0.68 0.10 240);
|
||||
--info-foreground: oklch(0.985 0 0);
|
||||
--gradient-primary: linear-gradient(135deg, #6b8cff, #88a4ff);
|
||||
--gradient-primary-hover: linear-gradient(135deg, #5f80f5, #7d9af5);
|
||||
--chart-1: oklch(0.62 0.12 260);
|
||||
--chart-2: oklch(0.65 0.12 155);
|
||||
--chart-3: oklch(0.75 0.12 80);
|
||||
--chart-4: oklch(0.62 0.12 300);
|
||||
--chart-5: oklch(0.70 0.12 25);
|
||||
--sidebar: oklch(0.985 0.004 250);
|
||||
--sidebar-foreground: oklch(0.18 0.02 250);
|
||||
--sidebar-primary: oklch(0.62 0.12 260);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.97 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.205 0 0);
|
||||
--sidebar-border: oklch(0.922 0 0);
|
||||
--sidebar-ring: oklch(0.708 0 0);
|
||||
--sidebar-accent: oklch(0.955 0.018 250);
|
||||
--sidebar-accent-foreground: oklch(0.25 0.04 250);
|
||||
--sidebar-border: oklch(0.925 0.008 250);
|
||||
--sidebar-ring: oklch(0.62 0.12 260);
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: oklch(0.145 0 0);
|
||||
--background: oklch(0.145 0.01 250);
|
||||
--foreground: oklch(0.985 0 0);
|
||||
--card: oklch(0.205 0 0);
|
||||
--card: oklch(0.2 0.015 250);
|
||||
--card-foreground: oklch(0.985 0 0);
|
||||
--popover: oklch(0.205 0 0);
|
||||
--popover: oklch(0.2 0.015 250);
|
||||
--popover-foreground: oklch(0.985 0 0);
|
||||
--primary: oklch(0.922 0 0);
|
||||
--primary-foreground: oklch(0.205 0 0);
|
||||
--secondary: oklch(0.269 0 0);
|
||||
--primary: oklch(0.72 0.10 260);
|
||||
--primary-foreground: oklch(0.15 0.02 250);
|
||||
--secondary: oklch(0.26 0.02 250);
|
||||
--secondary-foreground: oklch(0.985 0 0);
|
||||
--muted: oklch(0.269 0 0);
|
||||
--muted-foreground: oklch(0.708 0 0);
|
||||
--accent: oklch(0.269 0 0);
|
||||
--muted: oklch(0.26 0.02 250);
|
||||
--muted-foreground: oklch(0.7 0.02 250);
|
||||
--accent: oklch(0.26 0.025 250);
|
||||
--accent-foreground: oklch(0.985 0 0);
|
||||
--destructive: oklch(0.704 0.191 22.216);
|
||||
--destructive: oklch(0.70 0.14 25);
|
||||
--border: oklch(1 0 0 / 10%);
|
||||
--input: oklch(1 0 0 / 15%);
|
||||
--ring: oklch(0.556 0 0);
|
||||
--chart-1: oklch(0.488 0.243 264.376);
|
||||
--chart-2: oklch(0.696 0.17 162.48);
|
||||
--chart-3: oklch(0.769 0.188 70.08);
|
||||
--chart-4: oklch(0.627 0.265 303.9);
|
||||
--chart-5: oklch(0.645 0.246 16.439);
|
||||
--sidebar: oklch(0.205 0 0);
|
||||
--ring: oklch(0.72 0.10 260);
|
||||
--success: oklch(0.70 0.12 155);
|
||||
--success-foreground: oklch(0.985 0 0);
|
||||
--warning: oklch(0.78 0.12 80);
|
||||
--warning-foreground: oklch(0.18 0.04 60);
|
||||
--info: oklch(0.72 0.10 240);
|
||||
--info-foreground: oklch(0.985 0 0);
|
||||
--gradient-primary: linear-gradient(135deg, #7b9aff, #99b2ff);
|
||||
--gradient-primary-hover: linear-gradient(135deg, #6f8ef5, #8da6f5);
|
||||
--chart-1: oklch(0.72 0.10 260);
|
||||
--chart-2: oklch(0.70 0.12 155);
|
||||
--chart-3: oklch(0.78 0.12 80);
|
||||
--chart-4: oklch(0.68 0.12 300);
|
||||
--chart-5: oklch(0.72 0.12 25);
|
||||
--sidebar: oklch(0.2 0.015 250);
|
||||
--sidebar-foreground: oklch(0.985 0 0);
|
||||
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||
--sidebar-primary: oklch(0.72 0.10 260);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.269 0 0);
|
||||
--sidebar-accent: oklch(0.26 0.025 250);
|
||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||
--sidebar-border: oklch(1 0 0 / 10%);
|
||||
--sidebar-ring: oklch(0.556 0 0);
|
||||
--sidebar-ring: oklch(0.72 0.10 260);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -14,6 +14,12 @@ export const badgeVariants = cva(
|
||||
"border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
|
||||
destructive:
|
||||
"border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||
success:
|
||||
"border-transparent bg-success text-success-foreground [a&]:hover:bg-success/90",
|
||||
warning:
|
||||
"border-transparent bg-warning text-warning-foreground [a&]:hover:bg-warning/90",
|
||||
info:
|
||||
"border-transparent bg-info text-info-foreground [a&]:hover:bg-info/90",
|
||||
outline:
|
||||
"text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
|
||||
},
|
||||
|
||||
@@ -9,7 +9,7 @@ export const buttonVariants = cva(
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"bg-primary text-primary-foreground hover:bg-primary/90",
|
||||
"text-primary-foreground shadow-sm [background:var(--gradient-primary)] hover:[background:var(--gradient-primary-hover)]",
|
||||
destructive:
|
||||
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||
outline:
|
||||
|
||||
+47
-3
@@ -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 {
|
||||
@@ -166,6 +173,9 @@ export interface TaskStatisticsV2Item {
|
||||
successTask: number
|
||||
failTask: number
|
||||
pendingTask: number
|
||||
successTaskIds?: number[]
|
||||
failTaskIds?: number[]
|
||||
pendingTaskIds?: number[]
|
||||
}
|
||||
|
||||
export interface DashboardSummary {
|
||||
@@ -183,3 +193,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
|
||||
}
|
||||
|
||||
+740
-108
File diff suppressed because it is too large
Load Diff
@@ -2,7 +2,7 @@
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { message as toast } from '@/composables/useMessage'
|
||||
import { ArrowLeft, Save, Upload, X, Eye, HelpCircle } from 'lucide-vue-next'
|
||||
import { ArrowLeft, Save, Upload, X, Eye, HelpCircle, Play } from 'lucide-vue-next'
|
||||
import PageLoading from '@/components/common/PageLoading.vue'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
@@ -32,6 +32,10 @@ const formItems = ref<CpTaskFormItem[]>([])
|
||||
const savingForm = ref(false)
|
||||
const logicShowMap = ref<Record<string, boolean>>({})
|
||||
|
||||
// ---- 任务标题 ----
|
||||
const taskTitle = ref('')
|
||||
const savingTitle = ref(false)
|
||||
const executingNow = ref(false)
|
||||
// ---- 执行参数 ----
|
||||
const executeParams = ref<CpTaskExecute>({
|
||||
id: 0, frequency: 1, notification_type: 'none',
|
||||
@@ -275,6 +279,7 @@ async function loadExecute() {
|
||||
// getExecute 返回整个 cpTask 对象
|
||||
cpUserId.value = data.edges?.cp_user?.id || data.cp_user_id || 0
|
||||
taskType.value = data.task_type || ''
|
||||
taskTitle.value = data.title || ''
|
||||
executeParams.value = {
|
||||
id: data.id || 0, frequency: data.frequency ?? 1,
|
||||
notification_type: data.notification_type || 'none',
|
||||
@@ -319,6 +324,27 @@ async function saveExecute() {
|
||||
} finally { savingExecute.value = false }
|
||||
}
|
||||
|
||||
async function saveTitle() {
|
||||
if (!taskTitle.value.trim()) { toast.error('标题不能为空'); return }
|
||||
savingTitle.value = true
|
||||
try {
|
||||
await cpTaskApi.updateTitle(taskId.value, taskTitle.value.trim())
|
||||
toast.success('标题修改成功')
|
||||
} catch {
|
||||
// 全局拦截器已处理错误提示
|
||||
} finally { savingTitle.value = false }
|
||||
}
|
||||
|
||||
async function handleExecuteNow() {
|
||||
executingNow.value = true
|
||||
try {
|
||||
await cpTaskApi.executeNow(taskId.value)
|
||||
toast.success('任务已开始执行')
|
||||
} catch {
|
||||
// 全局拦截器已处理错误提示
|
||||
} finally { executingNow.value = false }
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await Promise.all([loadForm(), loadExecute()])
|
||||
loading.value = false
|
||||
@@ -332,6 +358,12 @@ onMounted(async () => {
|
||||
<ArrowLeft class="h-4 w-4" />
|
||||
</Button>
|
||||
<h2 class="text-2xl font-bold tracking-tight">任务配置</h2>
|
||||
<div class="ml-auto flex items-center gap-2">
|
||||
<Button variant="default" size="sm" :disabled="executingNow" @click="handleExecuteNow">
|
||||
<Play class="mr-1 h-4 w-4" />
|
||||
{{ executingNow ? '执行中...' : '立即执行' }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<PageLoading v-if="loading" text="加载中..." />
|
||||
@@ -447,6 +479,18 @@ onMounted(async () => {
|
||||
<CardContent>
|
||||
<div v-if="executeLoading" class="text-sm text-muted-foreground py-4">加载中...</div>
|
||||
<div v-else class="space-y-5">
|
||||
<div class="space-y-2">
|
||||
<Label for="taskTitle">任务标题</Label>
|
||||
<div class="flex items-center gap-2">
|
||||
<Input id="taskTitle" v-model="taskTitle" placeholder="请输入任务标题" class="flex-1" />
|
||||
<Button size="sm" variant="outline" :disabled="savingTitle" @click="saveTitle">
|
||||
{{ savingTitle ? '保存中' : '保存' }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label for="frequency">执行次数</Label>
|
||||
<Input id="frequency" type="number" v-model.number="executeParams.frequency" placeholder="请输入执行次数" :min="1" />
|
||||
|
||||
@@ -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-success' : 'text-destructive' },
|
||||
isSuccess ? '成功' : '失败',
|
||||
)
|
||||
}
|
||||
if (errorMessage) {
|
||||
return h('span', { class: 'text-destructive' }, '失败')
|
||||
}
|
||||
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) : '-'
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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,7 +25,9 @@ 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)
|
||||
const filterIds = ref('')
|
||||
|
||||
// ---- 确认弹窗状态 ----
|
||||
const confirmOpen = ref(false)
|
||||
@@ -59,9 +61,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 +93,7 @@ watch(userSearchText, (val) => {
|
||||
|
||||
function selectUser(user: { label: string; value: number }) {
|
||||
addUserId.value = user.value
|
||||
skipNextSearch = true
|
||||
userSearchText.value = user.label
|
||||
userSearchResults.value = []
|
||||
}
|
||||
@@ -159,6 +167,35 @@ 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)
|
||||
router.replace({ path: route.path })
|
||||
}
|
||||
const qIds = route.query.ids as string
|
||||
if (qIds) {
|
||||
filterIds.value = qIds
|
||||
router.replace({ path: route.path })
|
||||
}
|
||||
})
|
||||
|
||||
function prevStep() {
|
||||
addStep.value = 1
|
||||
selectedRecent.value = ''
|
||||
@@ -242,9 +279,9 @@ const taskStatusMap: Record<string, { label: string; variant: 'default' | 'secon
|
||||
}
|
||||
|
||||
const stepMap: Record<string, { label: string; color: string }> = {
|
||||
form: { label: '表单待填', color: 'text-yellow-600' },
|
||||
execute: { label: '执行参数待填', color: 'text-yellow-600' },
|
||||
complete: { label: '已完成', color: 'text-green-600' },
|
||||
form: { label: '表单待填', color: 'text-warning' },
|
||||
execute: { label: '执行参数待填', color: 'text-warning' },
|
||||
complete: { label: '已完成', color: 'text-success' },
|
||||
}
|
||||
|
||||
const notificationTypeMap: Record<string, string> = {
|
||||
@@ -376,9 +413,17 @@ const searchFields: SearchField[] = [
|
||||
]
|
||||
|
||||
const fetchFn = async (params: PageParams): Promise<PageResponse<CpTask>> => {
|
||||
if (filterIds.value) {
|
||||
(params as any).ids = filterIds.value
|
||||
}
|
||||
const res = await cpTaskApi.getList(params)
|
||||
return extractPageData(res)
|
||||
}
|
||||
|
||||
function clearFilterIds() {
|
||||
filterIds.value = ''
|
||||
tableRef.value?.refresh()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -391,6 +436,11 @@ const fetchFn = async (params: PageParams): Promise<PageResponse<CpTask>> => {
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div v-if="filterIds" class="flex items-center gap-2 rounded-md border border-primary/30 bg-primary/5 px-3 py-2 text-sm">
|
||||
<span class="text-primary">当前按统计数据筛选中,共 {{ filterIds.split(',').length }} 条任务</span>
|
||||
<Button size="sm" variant="ghost" class="ml-auto h-7 px-2 text-xs" @click="clearFilterIds">清除筛选</Button>
|
||||
</div>
|
||||
|
||||
<DataTable
|
||||
ref="tableRef"
|
||||
:columns="columns"
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
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 QRCode from 'qrcode'
|
||||
import { PlusCircle, Trash2, KeyRound, Smartphone, QrCode, Loader2 } from 'lucide-vue-next'
|
||||
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'
|
||||
@@ -31,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)
|
||||
@@ -475,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' }, () => '随机')
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -503,6 +511,11 @@ const columns: ColumnDef<CpUser, any>[] = [
|
||||
cell: ({ row }) => {
|
||||
const user = row.original
|
||||
return h('div', { class: 'flex items-center gap-1' }, [
|
||||
h(IconButton, {
|
||||
icon: ListPlus,
|
||||
tooltip: '添加任务',
|
||||
onClick: () => router.push({ path: '/cpdaily/task', query: { addUserId: String(user.id), addUsername: user.username } }),
|
||||
}),
|
||||
h(IconButton, {
|
||||
icon: Smartphone,
|
||||
tooltip: '自定义设备码',
|
||||
@@ -708,13 +721,13 @@ 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>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import { cpTaskApi } from '@/api/cpdaily'
|
||||
import { authApi } from '@/api/auth'
|
||||
@@ -10,6 +11,7 @@ import 'md-editor-v3/lib/preview.css'
|
||||
import type { TaskStatisticsV2Item, DashboardSummary } from '@/types/cpdaily'
|
||||
|
||||
const userStore = useUserStore()
|
||||
const router = useRouter()
|
||||
const statistics = ref<TaskStatisticsV2Item[]>([])
|
||||
const summary = ref<DashboardSummary | null>(null)
|
||||
const loading = ref(false)
|
||||
@@ -83,6 +85,11 @@ async function fetchAnnouncement() {
|
||||
}
|
||||
}
|
||||
|
||||
function goToTaskList(ids: number[] | undefined) {
|
||||
if (!ids || ids.length === 0) return
|
||||
router.push({ path: '/cpdaily/task/index', query: { ids: ids.join(',') } })
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchStatistics()
|
||||
fetchSummary()
|
||||
@@ -131,7 +138,7 @@ onMounted(() => {
|
||||
<span>今日消费</span>
|
||||
</div>
|
||||
<div class="text-lg sm:text-2xl font-bold">{{ summary?.todayCost ?? '--' }}</div>
|
||||
<div v-if="summary" class="flex items-center gap-1 text-xs mt-1" :class="todayCostDiff > 0 ? 'text-red-500' : todayCostDiff < 0 ? 'text-green-500' : 'text-muted-foreground'">
|
||||
<div v-if="summary" class="flex items-center gap-1 text-xs mt-1" :class="todayCostDiff > 0 ? 'text-destructive' : todayCostDiff < 0 ? 'text-success' : 'text-muted-foreground'">
|
||||
<TrendingUp v-if="todayCostDiff > 0" class="w-3 h-3" />
|
||||
<TrendingDown v-else-if="todayCostDiff < 0" class="w-3 h-3" />
|
||||
<Minus v-else class="w-3 h-3" />
|
||||
@@ -148,7 +155,7 @@ onMounted(() => {
|
||||
<span>近7日消费</span>
|
||||
</div>
|
||||
<div class="text-lg sm:text-2xl font-bold">{{ summary?.week1Cost ?? '--' }}</div>
|
||||
<div v-if="summary" class="flex items-center gap-1 text-xs mt-1" :class="weekCostDiff > 0 ? 'text-red-500' : weekCostDiff < 0 ? 'text-green-500' : 'text-muted-foreground'">
|
||||
<div v-if="summary" class="flex items-center gap-1 text-xs mt-1" :class="weekCostDiff > 0 ? 'text-destructive' : weekCostDiff < 0 ? 'text-success' : 'text-muted-foreground'">
|
||||
<TrendingUp v-if="weekCostDiff > 0" class="w-3 h-3" />
|
||||
<TrendingDown v-else-if="weekCostDiff < 0" class="w-3 h-3" />
|
||||
<Minus v-else class="w-3 h-3" />
|
||||
@@ -165,7 +172,7 @@ onMounted(() => {
|
||||
<span>今日任务数</span>
|
||||
</div>
|
||||
<div class="text-lg sm:text-2xl font-bold">{{ summary?.todayTaskCount ?? '--' }}</div>
|
||||
<div v-if="summary" class="flex items-center gap-1 text-xs mt-1" :class="taskCountDiff > 0 ? 'text-red-500' : taskCountDiff < 0 ? 'text-green-500' : 'text-muted-foreground'">
|
||||
<div v-if="summary" class="flex items-center gap-1 text-xs mt-1" :class="taskCountDiff > 0 ? 'text-destructive' : taskCountDiff < 0 ? 'text-success' : 'text-muted-foreground'">
|
||||
<TrendingUp v-if="taskCountDiff > 0" class="w-3 h-3" />
|
||||
<TrendingDown v-else-if="taskCountDiff < 0" class="w-3 h-3" />
|
||||
<Minus v-else class="w-3 h-3" />
|
||||
@@ -197,25 +204,32 @@ onMounted(() => {
|
||||
<div class="flex-1 flex h-5 sm:h-6 rounded overflow-hidden bg-muted/30">
|
||||
<div
|
||||
v-if="item.successTask > 0"
|
||||
class="bg-primary transition-all"
|
||||
class="bg-primary transition-all cursor-pointer hover:opacity-80"
|
||||
:style="{ width: (item.successTask / maxTaskCount * 100) + '%' }"
|
||||
:title="`成功: ${item.successTask}`"
|
||||
:title="`成功: ${item.successTask} — 点击查看`"
|
||||
@click="goToTaskList(item.successTaskIds)"
|
||||
></div>
|
||||
<div
|
||||
v-if="item.failTask > 0"
|
||||
class="bg-destructive transition-all"
|
||||
class="bg-destructive transition-all cursor-pointer hover:opacity-80"
|
||||
:style="{ width: (item.failTask / maxTaskCount * 100) + '%' }"
|
||||
:title="`失败: ${item.failTask}`"
|
||||
:title="`失败: ${item.failTask} — 点击查看`"
|
||||
@click="goToTaskList(item.failTaskIds)"
|
||||
></div>
|
||||
<div
|
||||
v-if="item.pendingTask > 0"
|
||||
class="bg-muted-foreground/30 transition-all"
|
||||
class="bg-muted-foreground/30 transition-all cursor-pointer hover:opacity-80"
|
||||
:style="{ width: (item.pendingTask / maxTaskCount * 100) + '%' }"
|
||||
:title="`待执行: ${item.pendingTask}`"
|
||||
:title="`待执行: ${item.pendingTask} — 点击查看`"
|
||||
@click="goToTaskList(item.pendingTaskIds)"
|
||||
></div>
|
||||
</div>
|
||||
<span class="text-xs text-muted-foreground w-16 sm:w-20 shrink-0">
|
||||
{{ item.successTask }}/{{ item.failTask }}/{{ item.pendingTask }}
|
||||
<span class="text-xs text-muted-foreground w-24 sm:w-28 shrink-0 flex gap-0.5">
|
||||
<span class="cursor-pointer text-primary hover:underline" @click="goToTaskList(item.successTaskIds)">{{ item.successTask }}</span>
|
||||
<span>/</span>
|
||||
<span class="cursor-pointer text-destructive hover:underline" @click="goToTaskList(item.failTaskIds)">{{ item.failTask }}</span>
|
||||
<span>/</span>
|
||||
<span class="cursor-pointer hover:underline" @click="goToTaskList(item.pendingTaskIds)">{{ item.pendingTask }}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -109,7 +109,7 @@ async function onConfirmCaptchaConfirm(result: { captchaKey: string; dots: Array
|
||||
</div>
|
||||
|
||||
<div v-else-if="confirmStatus === 'success'" class="text-center space-y-4">
|
||||
<CheckCircle class="h-12 w-12 mx-auto text-green-500" />
|
||||
<CheckCircle class="h-12 w-12 mx-auto text-success" />
|
||||
<h2 class="text-lg font-bold">转移成功</h2>
|
||||
<p class="text-muted-foreground text-sm">{{ confirmMessage }}</p>
|
||||
<router-link to="/login">
|
||||
|
||||
@@ -51,7 +51,7 @@ onMounted(async () => {
|
||||
</div>
|
||||
|
||||
<div v-else-if="status === 'success'" class="space-y-4">
|
||||
<CheckCircle class="h-12 w-12 mx-auto text-green-500" />
|
||||
<CheckCircle class="h-12 w-12 mx-auto text-success" />
|
||||
<h1 class="text-xl font-bold">验证成功</h1>
|
||||
<p class="text-muted-foreground text-sm">{{ msg }}</p>
|
||||
<template v-if="autoLoginSuccess">
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useForm } from 'vee-validate'
|
||||
import { toTypedSchema } from '@vee-validate/zod'
|
||||
@@ -13,20 +13,23 @@ import CaptchaDialog from '@/components/login/CaptchaDialog.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const allowRegister = ref(true)
|
||||
const requireInviteCode = ref(false)
|
||||
const checking = ref(true)
|
||||
|
||||
const schema = toTypedSchema(
|
||||
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 })
|
||||
|
||||
@@ -38,16 +41,26 @@ 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 res = await authApi.getPublicConfig('system.allowRegister')
|
||||
if (res.code === 200 && res.data) {
|
||||
allowRegister.value = res.data.value === 'true' || res.data.value === '1'
|
||||
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
|
||||
}
|
||||
@@ -76,6 +89,8 @@ 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) {
|
||||
if (err?.code === 10003) {
|
||||
@@ -88,6 +103,38 @@ async function onCaptchaConfirm(result: { captchaKey: string; dots: Array<{ x: n
|
||||
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>
|
||||
@@ -120,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">
|
||||
@@ -134,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>
|
||||
|
||||
@@ -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 {
|
||||
// 全局拦截器已处理错误提示
|
||||
} finally {
|
||||
announcementLoading.value = false
|
||||
}
|
||||
@@ -41,10 +49,8 @@ async function saveAnnouncement() {
|
||||
value: announcementContent.value,
|
||||
description: '系统公告',
|
||||
})
|
||||
toast.success('公告保存成功')
|
||||
originalAnnouncement.value = announcementContent.value
|
||||
} catch {
|
||||
// 全局拦截器已处理错误提示
|
||||
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 {
|
||||
// 全局拦截器已处理错误提示
|
||||
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 {
|
||||
// 全局拦截器已处理错误提示
|
||||
} 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>
|
||||
|
||||
@@ -33,7 +33,7 @@ const columns: ColumnDef<BalanceRecord, any>[] = [
|
||||
cell: ({ row }) => {
|
||||
const amount = row.getValue<string>('amount')
|
||||
const num = parseFloat(amount)
|
||||
const cls = num >= 0 ? 'text-green-600' : 'text-red-500'
|
||||
const cls = num >= 0 ? 'text-success' : 'text-destructive'
|
||||
return h('span', { class: `font-medium ${cls}` }, num >= 0 ? `+${amount}` : amount)
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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(
|
||||
@@ -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
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user