feat: 完善后端并新增 Vue3 WebUI
后端: - 修复登录校验、DB 并发(单例+WAL+纯Go sqlite)、会话密钥持久化等核心缺陷 - 新增 Timer CRUD/签到/手动触发、执行日志、仪表盘、驱动元数据等 API - 统一驱动接口: 带 context 的执行器、真实错误返回、敏感字段掩码、连通性测试 - 补全钉钉执行器; Timer 触发/提醒去重; 配置支持默认值与环境变量覆盖 前端: - Vite+Vue3+TS+Pinia+Element Plus, 由驱动元数据动态渲染配置表单 - 登录/仪表盘(倒计时环)/定时器/规则/账号/日志/设置页面, 支持暗色模式 - 主题色 #66ccff, 信号波纹 logo, go:embed 单二进制部署 构建: Dockerfile 增加前端构建阶段; 补充各驱动配置文档 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,24 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
Vendored
+3
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"recommendations": ["Vue.volar"]
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
# Vue 3 + TypeScript + Vite
|
||||
|
||||
This template should help get you started developing with Vue 3 and TypeScript in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
|
||||
|
||||
Learn more about the recommended Project Setup and IDE Support in the [Vue Docs TypeScript Guide](https://vuejs.org/guide/typescript/overview.html#project-setup).
|
||||
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Crect width='24' height='24' rx='6' fill='%2366ccff'/%3E%3Cg stroke='white' stroke-width='2' stroke-linecap='round' fill='none'%3E%3Cpath d='M8.6 15.4a4.8 4.8 0 0 1 0-6.8'/%3E%3Cpath d='M15.4 8.6a4.8 4.8 0 0 1 0 6.8'/%3E%3C/g%3E%3Ccircle cx='12' cy='12' r='2.2' fill='white'/%3E%3C/svg%3E" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>goodBaby 摇篮系统</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+1901
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vue-tsc -b && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@element-plus/icons-vue": "^2.3.2",
|
||||
"axios": "^1.18.1",
|
||||
"element-plus": "^2.14.3",
|
||||
"pinia": "^4.0.2",
|
||||
"vue": "^3.5.39",
|
||||
"vue-router": "^4.6.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.13.2",
|
||||
"@vitejs/plugin-vue": "^6.0.7",
|
||||
"@vue/tsconfig": "^0.9.1",
|
||||
"typescript": "~6.0.2",
|
||||
"vite": "^8.1.1",
|
||||
"vue-tsc": "^3.3.5"
|
||||
}
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 9.3 KiB |
@@ -0,0 +1,24 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg">
|
||||
<symbol id="bluesky-icon" viewBox="0 0 16 17">
|
||||
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
|
||||
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
|
||||
</symbol>
|
||||
<symbol id="discord-icon" viewBox="0 0 20 19">
|
||||
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
|
||||
</symbol>
|
||||
<symbol id="documentation-icon" viewBox="0 0 21 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
|
||||
</symbol>
|
||||
<symbol id="github-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
<symbol id="social-icon" viewBox="0 0 20 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
|
||||
</symbol>
|
||||
<symbol id="x-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.9 KiB |
@@ -0,0 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<RouterView />
|
||||
</template>
|
||||
@@ -0,0 +1,56 @@
|
||||
import axios, { AxiosError } from 'axios'
|
||||
import type { ApiResponse } from './types'
|
||||
|
||||
/** 业务错误:后端返回了非 200 的 code,message 可直接展示给用户 */
|
||||
export class ApiError extends Error {
|
||||
code: number
|
||||
|
||||
constructor(code: number, message: string) {
|
||||
super(message)
|
||||
this.code = code
|
||||
}
|
||||
}
|
||||
|
||||
const http = axios.create({
|
||||
baseURL: '/api/v1',
|
||||
timeout: 60_000,
|
||||
withCredentials: true,
|
||||
})
|
||||
|
||||
/** 未登录时的回调,由 router 注入,跳转到登录页 */
|
||||
let onUnauthorized: (() => void) | null = null
|
||||
|
||||
export function setUnauthorizedHandler(handler: () => void) {
|
||||
onUnauthorized = handler
|
||||
}
|
||||
|
||||
async function request<T>(promise: Promise<{ data: ApiResponse<T> }>): Promise<T> {
|
||||
try {
|
||||
const response = await promise
|
||||
return response.data.data
|
||||
} catch (error) {
|
||||
const axiosError = error as AxiosError<ApiResponse<string>>
|
||||
const status = axiosError.response?.status ?? 0
|
||||
const data = axiosError.response?.data
|
||||
|
||||
if (status === 401) {
|
||||
onUnauthorized?.()
|
||||
}
|
||||
|
||||
const message =
|
||||
typeof data?.data === 'string' && data.data !== ''
|
||||
? data.data
|
||||
: axiosError.message || '网络错误'
|
||||
throw new ApiError(status, message)
|
||||
}
|
||||
}
|
||||
|
||||
export const api = {
|
||||
get: <T>(url: string, params?: Record<string, unknown>) =>
|
||||
request<T>(http.get<ApiResponse<T>>(url, { params })),
|
||||
post: <T>(url: string, body?: unknown) =>
|
||||
request<T>(http.post<ApiResponse<T>>(url, body)),
|
||||
put: <T>(url: string, body?: unknown) =>
|
||||
request<T>(http.put<ApiResponse<T>>(url, body)),
|
||||
delete: <T>(url: string) => request<T>(http.delete<ApiResponse<T>>(url)),
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { api } from './client'
|
||||
import type {
|
||||
Account,
|
||||
AccountRequest,
|
||||
DashboardOverview,
|
||||
ExecutionLog,
|
||||
LogPage,
|
||||
Providers,
|
||||
Rule,
|
||||
RuleRequest,
|
||||
SiteInfo,
|
||||
Timer,
|
||||
TimerRequest,
|
||||
UserInfo,
|
||||
} from './types'
|
||||
|
||||
export const siteApi = {
|
||||
info: () => api.get<SiteInfo>('/site'),
|
||||
}
|
||||
|
||||
export const userApi = {
|
||||
register: (username: string, password: string) =>
|
||||
api.post<string>('/user/registry', { username, password }),
|
||||
login: (username: string, password: string) =>
|
||||
api.post<string>('/user/login', { username, password }),
|
||||
logout: () => api.post<string>('/user/logout'),
|
||||
info: () => api.get<UserInfo>('/user/info'),
|
||||
changePassword: (oldPassword: string, newPassword: string) =>
|
||||
api.post<string>('/user/password', {
|
||||
old_password: oldPassword,
|
||||
new_password: newPassword,
|
||||
}),
|
||||
updateNotify: (dingtalkConfig: string | null) =>
|
||||
api.put<string>('/user/notify', { dingtalk_config: dingtalkConfig }),
|
||||
}
|
||||
|
||||
export const dashboardApi = {
|
||||
overview: () => api.get<DashboardOverview>('/dashboard'),
|
||||
}
|
||||
|
||||
export const providerApi = {
|
||||
all: () => api.get<Providers>('/providers'),
|
||||
}
|
||||
|
||||
export const timerApi = {
|
||||
list: () => api.get<Timer[]>('/timers/'),
|
||||
get: (id: number) => api.get<{ timer: Timer; rule_count: number }>(`/timers/${id}`),
|
||||
create: (body: TimerRequest) => api.post<Timer>('/timers/', body),
|
||||
update: (id: number, body: TimerRequest) => api.put<Timer>(`/timers/${id}`, body),
|
||||
sign: (id: number) => api.post<Timer>(`/timers/${id}/sign`),
|
||||
signAll: () => api.post<{ signed: number; last_sign: number }>('/timers/sign'),
|
||||
trigger: (id: number) => api.post<{ total: number; failed: string[] }>(`/timers/${id}/trigger`),
|
||||
checkDelete: (id: number) => api.get<Rule[]>(`/timers/${id}/check`),
|
||||
remove: (id: number) => api.delete<string>(`/timers/${id}`),
|
||||
}
|
||||
|
||||
export const ruleApi = {
|
||||
list: (timerId?: number) =>
|
||||
api.get<Rule[]>('/rules/', timerId ? { timer_id: timerId } : undefined),
|
||||
create: (body: RuleRequest) => api.post<Rule>('/rules/', body),
|
||||
update: (id: number, body: RuleRequest) => api.put<Rule>(`/rules/${id}`, body),
|
||||
test: (id: number) => api.post<string>(`/rules/${id}/test`),
|
||||
remove: (id: number) => api.delete<string>(`/rules/${id}`),
|
||||
}
|
||||
|
||||
export const accountApi = {
|
||||
list: (type?: string) =>
|
||||
api.get<Account[]>('/accounts/', type ? { type } : undefined),
|
||||
create: (body: AccountRequest) => api.post<Account>('/accounts/', body),
|
||||
update: (id: number, body: AccountRequest) => api.put<Account>(`/accounts/${id}`, body),
|
||||
test: (id: number) => api.post<string>(`/accounts/${id}/test`),
|
||||
checkDelete: (id: number) => api.get<Rule[]>(`/accounts/${id}/check`),
|
||||
remove: (id: number) => api.delete<string>(`/accounts/${id}`),
|
||||
}
|
||||
|
||||
export const logApi = {
|
||||
list: (params: { page?: number; page_size?: number; rule_id?: number; success?: string }) =>
|
||||
api.get<LogPage>('/logs/', params),
|
||||
clear: () => api.delete<string>('/logs/'),
|
||||
}
|
||||
|
||||
export type { ExecutionLog }
|
||||
@@ -0,0 +1,157 @@
|
||||
// 与后端 model / internal/meta 对应的类型定义
|
||||
|
||||
export interface ApiResponse<T = unknown> {
|
||||
code: number
|
||||
data: T
|
||||
}
|
||||
|
||||
export interface UserInfo {
|
||||
id: number
|
||||
create_at: number
|
||||
username: string
|
||||
dingtalk_config: string | null
|
||||
}
|
||||
|
||||
export interface Timer {
|
||||
id: number
|
||||
uid: number
|
||||
name: string
|
||||
description: string
|
||||
enabled: boolean
|
||||
sign_deration_seconds: number
|
||||
last_sign: number
|
||||
remind_time_seconds: number
|
||||
last_remind: number
|
||||
triggered: boolean
|
||||
last_trigger: number
|
||||
create_at: number
|
||||
}
|
||||
|
||||
export interface TimerRequest {
|
||||
name: string
|
||||
description: string
|
||||
enabled?: boolean
|
||||
sign_deration_seconds: number
|
||||
remind_time_seconds: number
|
||||
}
|
||||
|
||||
export interface Rule {
|
||||
id: number
|
||||
uid: number
|
||||
timer_id: number
|
||||
name: string
|
||||
account_id: number
|
||||
type: string
|
||||
config_json: string
|
||||
enabled: boolean
|
||||
create_at: number
|
||||
}
|
||||
|
||||
export interface RuleRequest {
|
||||
name: string
|
||||
timer_id: number
|
||||
account_id: number
|
||||
type: string
|
||||
config_json: string
|
||||
enabled?: boolean
|
||||
}
|
||||
|
||||
export interface Account {
|
||||
id: number
|
||||
uid: number
|
||||
name: string
|
||||
type: string
|
||||
config: string
|
||||
create_at: number
|
||||
}
|
||||
|
||||
export interface AccountRequest {
|
||||
name: string
|
||||
type: string
|
||||
config: string
|
||||
}
|
||||
|
||||
export type FieldType =
|
||||
| 'string'
|
||||
| 'password'
|
||||
| 'textarea'
|
||||
| 'number'
|
||||
| 'string-list'
|
||||
| 'number-list'
|
||||
| 'bool'
|
||||
|
||||
export interface MetaField {
|
||||
key: string
|
||||
label: string
|
||||
type: FieldType
|
||||
required: boolean
|
||||
placeholder?: string
|
||||
help?: string
|
||||
secret?: boolean
|
||||
default?: unknown
|
||||
}
|
||||
|
||||
export interface AccountMeta {
|
||||
type: string
|
||||
label: string
|
||||
description?: string
|
||||
docs?: string
|
||||
testable: boolean
|
||||
fields: MetaField[]
|
||||
}
|
||||
|
||||
export interface RuleMeta {
|
||||
type: string
|
||||
label: string
|
||||
description?: string
|
||||
docs?: string
|
||||
account_type: string
|
||||
fields: MetaField[]
|
||||
}
|
||||
|
||||
export interface Providers {
|
||||
accounts: AccountMeta[]
|
||||
rules: RuleMeta[]
|
||||
}
|
||||
|
||||
export interface ExecutionLog {
|
||||
id: number
|
||||
uid: number
|
||||
rule_id: number
|
||||
rule_name: string
|
||||
rule_type: string
|
||||
timer_id: number
|
||||
trigger: 'timer' | 'manual' | 'remind'
|
||||
success: boolean
|
||||
message: string
|
||||
create_at: number
|
||||
}
|
||||
|
||||
export interface LogPage {
|
||||
total: number
|
||||
page: number
|
||||
page_size: number
|
||||
items: ExecutionLog[]
|
||||
}
|
||||
|
||||
export interface DashboardOverview {
|
||||
timer_count: number
|
||||
enabled_timers: number
|
||||
triggered_count: number
|
||||
rule_count: number
|
||||
account_count: number
|
||||
server_time: number
|
||||
recent_logs: ExecutionLog[]
|
||||
urgent_timer?: Timer
|
||||
urgent_seconds_left?: number
|
||||
}
|
||||
|
||||
export interface SiteInfo {
|
||||
name: string
|
||||
enable_registry: boolean
|
||||
need_initial_user: boolean
|
||||
check_interval_minutes: number
|
||||
}
|
||||
|
||||
/** 后端对 secret 字段的掩码占位符,原样提交时表示保留旧值 */
|
||||
export const MASK_PLACEHOLDER = '********'
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
@@ -0,0 +1,206 @@
|
||||
/* ============================================
|
||||
goodBaby 设计系统
|
||||
主色调:#66ccff 天依蓝
|
||||
============================================ */
|
||||
|
||||
:root {
|
||||
/* 品牌色板 */
|
||||
--gb-primary: #66ccff;
|
||||
/* 深一档的品牌色,用于浅色背景上的文字,保证可读性 */
|
||||
--gb-primary-deep: #2b9cd8;
|
||||
--gb-ink: #17191f;
|
||||
--gb-ink-soft: #232630;
|
||||
--gb-bg: #f4f6f8;
|
||||
--gb-card: #ffffff;
|
||||
|
||||
/* 覆盖 Element Plus 主色 */
|
||||
--el-color-primary: #66ccff;
|
||||
--el-color-primary-light-3: #8cd9ff;
|
||||
--el-color-primary-light-5: #a8e2ff;
|
||||
--el-color-primary-light-7: #c7edff;
|
||||
--el-color-primary-light-8: #d9f3ff;
|
||||
--el-color-primary-light-9: #ecf9ff;
|
||||
--el-color-primary-dark-2: #3fa9e0;
|
||||
|
||||
/* 圆角与阴影 */
|
||||
--el-border-radius-base: 8px;
|
||||
--gb-radius-card: 14px;
|
||||
--gb-shadow-card: 0 1px 2px rgb(16 24 40 / 0.04), 0 1px 8px rgb(16 24 40 / 0.05);
|
||||
--gb-shadow-hover: 0 6px 18px rgb(16 24 40 / 0.1);
|
||||
--gb-ease: cubic-bezier(0.22, 0.61, 0.36, 1);
|
||||
}
|
||||
|
||||
html.dark {
|
||||
--gb-bg: #101216;
|
||||
--gb-card: #1a1d23;
|
||||
--gb-shadow-card: 0 1px 2px rgb(0 0 0 / 0.4);
|
||||
--gb-shadow-hover: 0 6px 20px rgb(0 0 0 / 0.5);
|
||||
--el-bg-color: #1a1d23;
|
||||
--el-bg-color-overlay: #21252d;
|
||||
}
|
||||
|
||||
html,
|
||||
body,
|
||||
#app {
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background: var(--gb-bg);
|
||||
font-family:
|
||||
'Inter', 'Helvetica Neue', Helvetica, 'PingFang SC', 'Hiragino Sans GB',
|
||||
'Microsoft YaHei', Arial, sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
text-rendering: optimizeLegibility;
|
||||
}
|
||||
|
||||
/* ---------- 通用工具类 ---------- */
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.page-header h2 {
|
||||
margin: 0;
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: var(--el-text-color-secondary);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.mono {
|
||||
font-family: ui-monospace, SFMono-Regular, Consolas, monospace;
|
||||
}
|
||||
|
||||
/* ---------- Element Plus 外观微调 ---------- */
|
||||
|
||||
.el-card {
|
||||
border: none;
|
||||
border-radius: var(--gb-radius-card);
|
||||
box-shadow: var(--gb-shadow-card);
|
||||
transition:
|
||||
box-shadow 0.3s var(--gb-ease),
|
||||
transform 0.3s var(--gb-ease);
|
||||
}
|
||||
|
||||
.el-card__header {
|
||||
font-weight: 600;
|
||||
border-bottom: 1px solid var(--el-border-color-extra-light);
|
||||
}
|
||||
|
||||
.el-button {
|
||||
transition:
|
||||
all 0.2s var(--gb-ease),
|
||||
transform 0.15s var(--gb-ease);
|
||||
}
|
||||
|
||||
/* #66ccff 底色偏浅,主按钮用深墨色文字保证对比度 */
|
||||
.el-button--primary {
|
||||
--el-button-text-color: #0b2b3d;
|
||||
--el-button-hover-text-color: #0b2b3d;
|
||||
--el-button-active-text-color: #0b2b3d;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.el-button:not(.is-disabled):active {
|
||||
transform: scale(0.97);
|
||||
}
|
||||
|
||||
.el-dialog {
|
||||
border-radius: 16px;
|
||||
}
|
||||
|
||||
.el-table {
|
||||
--el-table-border-color: var(--el-border-color-extra-light);
|
||||
--el-table-header-bg-color: transparent;
|
||||
}
|
||||
|
||||
.el-table th.el-table__cell {
|
||||
font-weight: 600;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
.el-tag {
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
/* 提示条统一柔和外观 */
|
||||
.el-alert {
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
/* ---------- 页面切换动画 ---------- */
|
||||
|
||||
.page-enter-active,
|
||||
.page-leave-active {
|
||||
transition:
|
||||
opacity 0.22s var(--gb-ease),
|
||||
transform 0.22s var(--gb-ease);
|
||||
}
|
||||
|
||||
.page-enter-from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
|
||||
.page-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(-6px);
|
||||
}
|
||||
|
||||
/* 列表项入场 */
|
||||
@keyframes gb-rise {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(12px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.gb-rise {
|
||||
animation: gb-rise 0.35s var(--gb-ease) both;
|
||||
}
|
||||
|
||||
/* 危险状态的呼吸效果 */
|
||||
@keyframes gb-pulse {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.55;
|
||||
}
|
||||
}
|
||||
|
||||
.gb-pulse {
|
||||
animation: gb-pulse 1.6s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* ---------- 滚动条 ---------- */
|
||||
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: rgb(140 145 155 / 0.35);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: rgb(140 145 155 / 0.55);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 8.5 KiB |
@@ -0,0 +1,179 @@
|
||||
<script setup lang="ts">
|
||||
// 根据驱动元数据(MetaField[])动态渲染配置表单。
|
||||
// 通过 v-model 双向绑定一个 JSON 字符串,内部维护解析后的对象。
|
||||
import { ref, watch } from 'vue'
|
||||
import type { MetaField } from '@/api/types'
|
||||
|
||||
const props = defineProps<{
|
||||
fields: MetaField[]
|
||||
modelValue: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', value: string): void
|
||||
}>()
|
||||
|
||||
type ConfigValue = Record<string, unknown>
|
||||
|
||||
const form = ref<ConfigValue>({})
|
||||
|
||||
/** 字符串数组/数字数组字段在 UI 中用多行文本编辑,每行一项 */
|
||||
const listDrafts = ref<Record<string, string>>({})
|
||||
|
||||
function defaultFor(field: MetaField): unknown {
|
||||
if (field.default !== undefined && field.default !== null) return field.default
|
||||
switch (field.type) {
|
||||
case 'number':
|
||||
return undefined
|
||||
case 'bool':
|
||||
return false
|
||||
case 'string-list':
|
||||
case 'number-list':
|
||||
return []
|
||||
default:
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
function parseIncoming(raw: string) {
|
||||
let parsed: ConfigValue = {}
|
||||
if (raw) {
|
||||
try {
|
||||
parsed = JSON.parse(raw) as ConfigValue
|
||||
} catch {
|
||||
parsed = {}
|
||||
}
|
||||
}
|
||||
|
||||
const next: ConfigValue = {}
|
||||
const drafts: Record<string, string> = {}
|
||||
for (const field of props.fields) {
|
||||
const value = parsed[field.key] ?? defaultFor(field)
|
||||
next[field.key] = value
|
||||
if (field.type === 'string-list' || field.type === 'number-list') {
|
||||
drafts[field.key] = Array.isArray(value) ? value.join('\n') : ''
|
||||
}
|
||||
}
|
||||
form.value = next
|
||||
listDrafts.value = drafts
|
||||
}
|
||||
|
||||
function serialize(): string {
|
||||
const out: ConfigValue = {}
|
||||
for (const field of props.fields) {
|
||||
let value = form.value[field.key]
|
||||
if (field.type === 'string-list') {
|
||||
value = (listDrafts.value[field.key] ?? '')
|
||||
.split('\n')
|
||||
.map((s) => s.trim())
|
||||
.filter((s) => s !== '')
|
||||
} else if (field.type === 'number-list') {
|
||||
value = (listDrafts.value[field.key] ?? '')
|
||||
.split('\n')
|
||||
.map((s) => s.trim())
|
||||
.filter((s) => s !== '')
|
||||
.map((s) => Number(s))
|
||||
.filter((n) => !Number.isNaN(n))
|
||||
} else if (field.type === 'number') {
|
||||
if (value === '' || value === undefined || value === null) continue
|
||||
value = Number(value)
|
||||
}
|
||||
if (value === '' || value === undefined) continue
|
||||
out[field.key] = value
|
||||
}
|
||||
return JSON.stringify(out)
|
||||
}
|
||||
|
||||
let syncing = false
|
||||
|
||||
watch(
|
||||
() => [props.modelValue, props.fields] as const,
|
||||
() => {
|
||||
if (syncing) return
|
||||
parseIncoming(props.modelValue)
|
||||
},
|
||||
{ immediate: true, deep: false },
|
||||
)
|
||||
|
||||
watch(
|
||||
[form, listDrafts],
|
||||
() => {
|
||||
syncing = true
|
||||
emit('update:modelValue', serialize())
|
||||
// 下一轮微任务再解除,避免回环触发 parseIncoming
|
||||
queueMicrotask(() => {
|
||||
syncing = false
|
||||
})
|
||||
},
|
||||
{ deep: true },
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="config-form">
|
||||
<el-form-item
|
||||
v-for="field in fields"
|
||||
:key="field.key"
|
||||
:label="field.label"
|
||||
:required="field.required"
|
||||
>
|
||||
<!-- 单行文本 -->
|
||||
<el-input
|
||||
v-if="field.type === 'string'"
|
||||
v-model="form[field.key] as string"
|
||||
:placeholder="field.placeholder"
|
||||
clearable
|
||||
/>
|
||||
|
||||
<!-- 密码 -->
|
||||
<el-input
|
||||
v-else-if="field.type === 'password'"
|
||||
v-model="form[field.key] as string"
|
||||
type="password"
|
||||
show-password
|
||||
:placeholder="field.placeholder"
|
||||
/>
|
||||
|
||||
<!-- 多行文本 -->
|
||||
<el-input
|
||||
v-else-if="field.type === 'textarea'"
|
||||
v-model="form[field.key] as string"
|
||||
type="textarea"
|
||||
:rows="4"
|
||||
:placeholder="field.placeholder"
|
||||
/>
|
||||
|
||||
<!-- 数字 -->
|
||||
<el-input-number
|
||||
v-else-if="field.type === 'number'"
|
||||
v-model="form[field.key] as number"
|
||||
:min="0"
|
||||
:controls="false"
|
||||
:placeholder="field.placeholder"
|
||||
style="width: 100%"
|
||||
/>
|
||||
|
||||
<!-- 开关 -->
|
||||
<el-switch v-else-if="field.type === 'bool'" v-model="form[field.key] as boolean" />
|
||||
|
||||
<!-- 列表:每行一项 -->
|
||||
<el-input
|
||||
v-else
|
||||
v-model="listDrafts[field.key]"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
:placeholder="field.placeholder ? `${field.placeholder}\n(每行一项)` : '每行一项'"
|
||||
/>
|
||||
|
||||
<div v-if="field.help" class="muted field-help">{{ field.help }}</div>
|
||||
</el-form-item>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.field-help {
|
||||
line-height: 1.5;
|
||||
margin-top: 4px;
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,22 @@
|
||||
<script setup lang="ts">
|
||||
// 品牌标志:信号波纹,寓意「定期发出的平安信号」
|
||||
defineProps<{ size?: number }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<svg
|
||||
:width="size ?? 22"
|
||||
:height="size ?? 22"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
>
|
||||
<circle cx="12" cy="12" r="2.2" fill="currentColor" stroke="none" />
|
||||
<path d="M8.6 15.4a4.8 4.8 0 0 1 0-6.8" />
|
||||
<path d="M15.4 8.6a4.8 4.8 0 0 1 0 6.8" />
|
||||
<path d="M5.8 18.2a8.8 8.8 0 0 1 0-12.4" />
|
||||
<path d="M18.2 5.8a8.8 8.8 0 0 1 0 12.4" />
|
||||
</svg>
|
||||
</template>
|
||||
@@ -0,0 +1,313 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import {
|
||||
Odometer,
|
||||
Timer as TimerIcon,
|
||||
Operation,
|
||||
User,
|
||||
Document,
|
||||
Setting,
|
||||
SwitchButton,
|
||||
Moon,
|
||||
Sunny,
|
||||
} from '@element-plus/icons-vue'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import LogoMark from '@/components/LogoMark.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const userStore = useUserStore()
|
||||
|
||||
const menus = [
|
||||
{ path: '/dashboard', title: '仪表盘', icon: Odometer },
|
||||
{ path: '/timers', title: '定时器', icon: TimerIcon },
|
||||
{ path: '/rules', title: '规则', icon: Operation },
|
||||
{ path: '/accounts', title: '账号', icon: User },
|
||||
{ path: '/logs', title: '执行日志', icon: Document },
|
||||
{ path: '/settings', title: '设置', icon: Setting },
|
||||
]
|
||||
|
||||
const activePath = computed(() => `/${route.path.split('/')[1] ?? ''}`)
|
||||
|
||||
const isDark = ref(document.documentElement.classList.contains('dark'))
|
||||
|
||||
function toggleDark() {
|
||||
isDark.value = !isDark.value
|
||||
document.documentElement.classList.toggle('dark', isDark.value)
|
||||
localStorage.setItem('gb-theme', isDark.value ? 'dark' : 'light')
|
||||
}
|
||||
|
||||
// 恢复上次的主题选择
|
||||
if (localStorage.getItem('gb-theme') === 'dark' && !isDark.value) {
|
||||
toggleDark()
|
||||
}
|
||||
|
||||
async function handleLogout() {
|
||||
await userStore.logout()
|
||||
ElMessage.success('已退出登录')
|
||||
router.push({ name: 'login' })
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="layout">
|
||||
<!-- 侧边栏 -->
|
||||
<aside class="aside">
|
||||
<div class="logo">
|
||||
<div class="logo-mark"><LogoMark :size="22" /></div>
|
||||
<div class="logo-name">
|
||||
<span class="logo-text">goodBaby</span>
|
||||
<span class="logo-sub">摇篮系统</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav class="nav">
|
||||
<RouterLink
|
||||
v-for="item in menus"
|
||||
:key="item.path"
|
||||
:to="item.path"
|
||||
class="nav-item"
|
||||
:class="{ active: activePath === item.path }"
|
||||
>
|
||||
<el-icon :size="17"><component :is="item.icon" /></el-icon>
|
||||
<span>{{ item.title }}</span>
|
||||
</RouterLink>
|
||||
</nav>
|
||||
|
||||
<div class="aside-footer">
|
||||
<div class="footer-quote">goodBaby v2</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- 主区域 -->
|
||||
<div class="content">
|
||||
<header class="header">
|
||||
<div class="header-title">{{ route.meta.title ?? '' }}</div>
|
||||
<div class="header-actions">
|
||||
<button class="icon-btn" :title="isDark ? '切换到亮色' : '切换到暗色'" @click="toggleDark">
|
||||
<el-icon :size="17"><Moon v-if="!isDark" /><Sunny v-else /></el-icon>
|
||||
</button>
|
||||
<el-dropdown>
|
||||
<span class="user-chip">
|
||||
<span class="user-avatar">{{ userStore.user?.username?.[0]?.toUpperCase() }}</span>
|
||||
{{ userStore.user?.username }}
|
||||
</span>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item @click="router.push('/settings')">
|
||||
<el-icon><Setting /></el-icon>设置
|
||||
</el-dropdown-item>
|
||||
<el-dropdown-item divided @click="handleLogout">
|
||||
<el-icon><SwitchButton /></el-icon>退出登录
|
||||
</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="main">
|
||||
<RouterView v-slot="{ Component }">
|
||||
<Transition name="page" mode="out-in">
|
||||
<component :is="Component" />
|
||||
</Transition>
|
||||
</RouterView>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.layout {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
/* ---------- 侧边栏:墨色 + 药丸导航 ---------- */
|
||||
|
||||
.aside {
|
||||
width: 224px;
|
||||
flex-shrink: 0;
|
||||
background: linear-gradient(180deg, var(--gb-ink) 0%, var(--gb-ink-soft) 100%);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 20px 14px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 4px 8px 22px;
|
||||
}
|
||||
|
||||
.logo-mark {
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
border-radius: 12px;
|
||||
background: linear-gradient(135deg, var(--gb-primary) 0%, #3fa9e0 100%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #0b2b3d;
|
||||
box-shadow: 0 4px 12px rgb(102 204 255 / 0.45);
|
||||
}
|
||||
|
||||
.logo-name {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.logo-text {
|
||||
color: #fff;
|
||||
font-size: 17px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.logo-sub {
|
||||
color: rgb(255 255 255 / 0.45);
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.2em;
|
||||
}
|
||||
|
||||
.nav {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 11px;
|
||||
padding: 10px 13px;
|
||||
border-radius: 10px;
|
||||
color: rgb(255 255 255 / 0.62);
|
||||
text-decoration: none;
|
||||
font-size: 14px;
|
||||
transition:
|
||||
background 0.2s var(--gb-ease),
|
||||
color 0.2s var(--gb-ease),
|
||||
transform 0.2s var(--gb-ease);
|
||||
}
|
||||
|
||||
.nav-item:hover {
|
||||
background: rgb(255 255 255 / 0.07);
|
||||
color: rgb(255 255 255 / 0.9);
|
||||
transform: translateX(2px);
|
||||
}
|
||||
|
||||
.nav-item.active {
|
||||
background: linear-gradient(135deg, var(--gb-primary) 0%, #3fa9e0 100%);
|
||||
color: #0b2b3d;
|
||||
font-weight: 600;
|
||||
box-shadow: 0 4px 14px rgb(102 204 255 / 0.4);
|
||||
}
|
||||
|
||||
.aside-footer {
|
||||
margin-top: auto;
|
||||
padding: 12px 8px 4px;
|
||||
}
|
||||
|
||||
.footer-quote {
|
||||
color: rgb(255 255 255 / 0.28);
|
||||
font-size: 12px;
|
||||
line-height: 1.9;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
/* ---------- 主区域 ---------- */
|
||||
|
||||
.content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.header {
|
||||
height: 60px;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 28px;
|
||||
background: color-mix(in srgb, var(--gb-card) 82%, transparent);
|
||||
backdrop-filter: blur(10px);
|
||||
border-bottom: 1px solid var(--el-border-color-extra-light);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.header-title {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.icon-btn {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
background: transparent;
|
||||
color: var(--el-text-color-regular);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: background 0.2s var(--gb-ease);
|
||||
}
|
||||
|
||||
.icon-btn:hover {
|
||||
background: var(--el-fill-color);
|
||||
}
|
||||
|
||||
.user-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
cursor: pointer;
|
||||
color: var(--el-text-color-primary);
|
||||
font-size: 14px;
|
||||
outline: none;
|
||||
padding: 4px 10px 4px 4px;
|
||||
border-radius: 999px;
|
||||
transition: background 0.2s var(--gb-ease);
|
||||
}
|
||||
|
||||
.user-chip:hover {
|
||||
background: var(--el-fill-color);
|
||||
}
|
||||
|
||||
.user-avatar {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, var(--gb-primary) 0%, #3fa9e0 100%);
|
||||
color: #0b2b3d;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.main {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 24px 28px 40px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,16 @@
|
||||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import ElementPlus from 'element-plus'
|
||||
import zhCn from 'element-plus/es/locale/lang/zh-cn'
|
||||
import 'element-plus/dist/index.css'
|
||||
import 'element-plus/theme-chalk/dark/css-vars.css'
|
||||
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
import './assets/main.css'
|
||||
|
||||
const app = createApp(App)
|
||||
app.use(createPinia())
|
||||
app.use(router)
|
||||
app.use(ElementPlus, { locale: zhCn })
|
||||
app.mount('#app')
|
||||
@@ -0,0 +1,83 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import { setUnauthorizedHandler } from '@/api/client'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes: [
|
||||
{
|
||||
path: '/login',
|
||||
name: 'login',
|
||||
component: () => import('@/views/LoginView.vue'),
|
||||
meta: { public: true },
|
||||
},
|
||||
{
|
||||
path: '/',
|
||||
component: () => import('@/layouts/MainLayout.vue'),
|
||||
children: [
|
||||
{ path: '', redirect: '/dashboard' },
|
||||
{
|
||||
path: 'dashboard',
|
||||
name: 'dashboard',
|
||||
component: () => import('@/views/DashboardView.vue'),
|
||||
meta: { title: '仪表盘' },
|
||||
},
|
||||
{
|
||||
path: 'timers',
|
||||
name: 'timers',
|
||||
component: () => import('@/views/TimersView.vue'),
|
||||
meta: { title: '定时器' },
|
||||
},
|
||||
{
|
||||
path: 'rules',
|
||||
name: 'rules',
|
||||
component: () => import('@/views/RulesView.vue'),
|
||||
meta: { title: '规则' },
|
||||
},
|
||||
{
|
||||
path: 'accounts',
|
||||
name: 'accounts',
|
||||
component: () => import('@/views/AccountsView.vue'),
|
||||
meta: { title: '账号' },
|
||||
},
|
||||
{
|
||||
path: 'logs',
|
||||
name: 'logs',
|
||||
component: () => import('@/views/LogsView.vue'),
|
||||
meta: { title: '执行日志' },
|
||||
},
|
||||
{
|
||||
path: 'settings',
|
||||
name: 'settings',
|
||||
component: () => import('@/views/SettingsView.vue'),
|
||||
meta: { title: '设置' },
|
||||
},
|
||||
],
|
||||
},
|
||||
{ path: '/:pathMatch(.*)*', redirect: '/dashboard' },
|
||||
],
|
||||
})
|
||||
|
||||
router.beforeEach(async (to) => {
|
||||
if (to.meta.public) return true
|
||||
|
||||
const userStore = useUserStore()
|
||||
if (!userStore.loaded) {
|
||||
await userStore.fetchUser()
|
||||
}
|
||||
if (!userStore.user) {
|
||||
return { name: 'login', query: { redirect: to.fullPath } }
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
// API 返回 401 时清空用户状态并跳到登录页
|
||||
setUnauthorizedHandler(() => {
|
||||
const userStore = useUserStore()
|
||||
userStore.clear()
|
||||
if (router.currentRoute.value.name !== 'login') {
|
||||
router.push({ name: 'login' })
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,46 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { computed, ref } from 'vue'
|
||||
import { providerApi } from '@/api'
|
||||
import type { AccountMeta, Providers, RuleMeta } from '@/api/types'
|
||||
|
||||
/** 驱动元数据:账号/规则类型的表单描述,登录后拉取一次全局共享 */
|
||||
export const useMetaStore = defineStore('meta', () => {
|
||||
const providers = ref<Providers | null>(null)
|
||||
|
||||
async function ensureLoaded() {
|
||||
if (!providers.value) {
|
||||
providers.value = await providerApi.all()
|
||||
}
|
||||
return providers.value
|
||||
}
|
||||
|
||||
const accountMetas = computed<AccountMeta[]>(() => providers.value?.accounts ?? [])
|
||||
const ruleMetas = computed<RuleMeta[]>(() => providers.value?.rules ?? [])
|
||||
|
||||
function accountMeta(type: string): AccountMeta | undefined {
|
||||
return accountMetas.value.find((m) => m.type === type)
|
||||
}
|
||||
|
||||
function ruleMeta(type: string): RuleMeta | undefined {
|
||||
return ruleMetas.value.find((m) => m.type === type)
|
||||
}
|
||||
|
||||
function accountLabel(type: string): string {
|
||||
return accountMeta(type)?.label ?? type
|
||||
}
|
||||
|
||||
function ruleLabel(type: string): string {
|
||||
return ruleMeta(type)?.label ?? type
|
||||
}
|
||||
|
||||
return {
|
||||
providers,
|
||||
accountMetas,
|
||||
ruleMetas,
|
||||
ensureLoaded,
|
||||
accountMeta,
|
||||
ruleMeta,
|
||||
accountLabel,
|
||||
ruleLabel,
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,35 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import { userApi } from '@/api'
|
||||
import type { UserInfo } from '@/api/types'
|
||||
|
||||
export const useUserStore = defineStore('user', () => {
|
||||
const user = ref<UserInfo | null>(null)
|
||||
const loaded = ref(false)
|
||||
|
||||
/** 拉取当前登录用户,未登录时静默失败 */
|
||||
async function fetchUser(): Promise<UserInfo | null> {
|
||||
try {
|
||||
user.value = await userApi.info()
|
||||
} catch {
|
||||
user.value = null
|
||||
} finally {
|
||||
loaded.value = true
|
||||
}
|
||||
return user.value
|
||||
}
|
||||
|
||||
async function logout() {
|
||||
try {
|
||||
await userApi.logout()
|
||||
} finally {
|
||||
user.value = null
|
||||
}
|
||||
}
|
||||
|
||||
function clear() {
|
||||
user.value = null
|
||||
}
|
||||
|
||||
return { user, loaded, fetchUser, logout, clear }
|
||||
})
|
||||
@@ -0,0 +1,41 @@
|
||||
/** 时间与时长的展示工具 */
|
||||
|
||||
export function formatDateTime(unixSeconds: number): string {
|
||||
if (!unixSeconds) return '-'
|
||||
const date = new Date(unixSeconds * 1000)
|
||||
const pad = (n: number) => String(n).padStart(2, '0')
|
||||
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`
|
||||
}
|
||||
|
||||
/** 把秒数格式化为 "3天 4小时" 这样的可读时长 */
|
||||
export function formatDuration(totalSeconds: number): string {
|
||||
if (totalSeconds <= 0) return '0秒'
|
||||
|
||||
const units: Array<[number, string]> = [
|
||||
[86400, '天'],
|
||||
[3600, '小时'],
|
||||
[60, '分钟'],
|
||||
[1, '秒'],
|
||||
]
|
||||
|
||||
const parts: string[] = []
|
||||
let remain = Math.floor(totalSeconds)
|
||||
for (const [size, label] of units) {
|
||||
if (remain >= size) {
|
||||
parts.push(`${Math.floor(remain / size)}${label}`)
|
||||
remain %= size
|
||||
}
|
||||
if (parts.length >= 2) break
|
||||
}
|
||||
return parts.join(' ') || '0秒'
|
||||
}
|
||||
|
||||
/** 常用签到周期预设(秒) */
|
||||
export const durationPresets: Array<{ label: string; seconds: number }> = [
|
||||
{ label: '1 天', seconds: 86400 },
|
||||
{ label: '3 天', seconds: 3 * 86400 },
|
||||
{ label: '7 天', seconds: 7 * 86400 },
|
||||
{ label: '14 天', seconds: 14 * 86400 },
|
||||
{ label: '30 天', seconds: 30 * 86400 },
|
||||
{ label: '90 天', seconds: 90 * 86400 },
|
||||
]
|
||||
@@ -0,0 +1,207 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Connection, Plus } from '@element-plus/icons-vue'
|
||||
import { accountApi } from '@/api'
|
||||
import { ApiError } from '@/api/client'
|
||||
import type { Account } from '@/api/types'
|
||||
import { useMetaStore } from '@/stores/meta'
|
||||
import { formatDateTime } from '@/utils/format'
|
||||
import ConfigForm from '@/components/ConfigForm.vue'
|
||||
|
||||
const metaStore = useMetaStore()
|
||||
|
||||
const accounts = ref<Account[]>([])
|
||||
const loading = ref(false)
|
||||
const testingId = ref<number | null>(null)
|
||||
|
||||
// ---- 创建 / 编辑对话框 ----
|
||||
const dialogVisible = ref(false)
|
||||
const saving = ref(false)
|
||||
const editingId = ref<number | null>(null)
|
||||
|
||||
const form = ref({
|
||||
name: '',
|
||||
type: '',
|
||||
config: '',
|
||||
})
|
||||
|
||||
const dialogTitle = computed(() => (editingId.value === null ? '添加账号' : '编辑账号'))
|
||||
const currentMeta = computed(() => metaStore.accountMeta(form.value.type))
|
||||
|
||||
async function refresh() {
|
||||
loading.value = true
|
||||
try {
|
||||
accounts.value = await accountApi.list()
|
||||
} catch (error) {
|
||||
ElMessage.error(error instanceof ApiError ? error.message : '加载失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
editingId.value = null
|
||||
form.value = { name: '', type: metaStore.accountMetas[0]?.type ?? '', config: '' }
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
function openEdit(account: Account) {
|
||||
editingId.value = account.id
|
||||
form.value = { name: account.name, type: account.type, config: account.config }
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (!form.value.name || !form.value.type) {
|
||||
ElMessage.warning('请填写账号名称并选择类型')
|
||||
return
|
||||
}
|
||||
|
||||
saving.value = true
|
||||
try {
|
||||
if (editingId.value === null) {
|
||||
await accountApi.create(form.value)
|
||||
ElMessage.success('账号已添加')
|
||||
} else {
|
||||
await accountApi.update(editingId.value, form.value)
|
||||
ElMessage.success('账号已更新')
|
||||
}
|
||||
dialogVisible.value = false
|
||||
await refresh()
|
||||
} catch (error) {
|
||||
ElMessage.error(error instanceof ApiError ? error.message : '保存失败')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function test(account: Account) {
|
||||
testingId.value = account.id
|
||||
try {
|
||||
await accountApi.test(account.id)
|
||||
ElMessage.success(`「${account.name}」测试通过`)
|
||||
} catch (error) {
|
||||
ElMessage.error(error instanceof ApiError ? error.message : '测试失败')
|
||||
} finally {
|
||||
testingId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(account: Account) {
|
||||
try {
|
||||
const affected = await accountApi.checkDelete(account.id)
|
||||
const warning =
|
||||
affected.length > 0
|
||||
? `删除后,使用该账号的 ${affected.length} 条规则也会被一并删除:${affected
|
||||
.map((r) => r.name)
|
||||
.join('、')}`
|
||||
: '没有规则使用该账号。'
|
||||
|
||||
await ElMessageBox.confirm(warning, `删除账号「${account.name}」?`, {
|
||||
type: 'warning',
|
||||
confirmButtonText: '删除',
|
||||
confirmButtonClass: 'el-button--danger',
|
||||
cancelButtonText: '取消',
|
||||
})
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await accountApi.remove(account.id)
|
||||
ElMessage.success('已删除')
|
||||
await refresh()
|
||||
} catch (error) {
|
||||
ElMessage.error(error instanceof ApiError ? error.message : '删除失败')
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await metaStore.ensureLoaded()
|
||||
await refresh()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-loading="loading">
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h2>账号</h2>
|
||||
<div class="muted">保存第三方服务的凭据,供规则执行时使用。敏感字段保存后显示为 ********</div>
|
||||
</div>
|
||||
<el-button type="primary" :icon="Plus" @click="openCreate">添加账号</el-button>
|
||||
</div>
|
||||
|
||||
<el-empty v-if="!loading && accounts.length === 0" description="还没有账号">
|
||||
<el-button type="primary" @click="openCreate">添加账号</el-button>
|
||||
</el-empty>
|
||||
|
||||
<el-card v-else class="gb-rise">
|
||||
<el-table :data="accounts">
|
||||
<el-table-column prop="name" label="名称" min-width="140" />
|
||||
<el-table-column label="类型" width="160">
|
||||
<template #default="{ row }">
|
||||
<el-tag>{{ metaStore.accountLabel(row.type) }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="创建时间" width="180">
|
||||
<template #default="{ row }">{{ formatDateTime(row.create_at) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="260">
|
||||
<template #default="{ row }">
|
||||
<el-button
|
||||
v-if="metaStore.accountMeta(row.type)?.testable"
|
||||
size="small"
|
||||
:icon="Connection"
|
||||
:loading="testingId === row.id"
|
||||
@click="test(row)"
|
||||
>
|
||||
测试
|
||||
</el-button>
|
||||
<el-button size="small" @click="openEdit(row)">编辑</el-button>
|
||||
<el-button size="small" type="danger" plain @click="remove(row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
|
||||
<!-- 创建/编辑对话框 -->
|
||||
<el-dialog v-model="dialogVisible" :title="dialogTitle" width="520px">
|
||||
<el-form label-width="110px">
|
||||
<el-form-item label="账号名称" required>
|
||||
<el-input v-model="form.name" placeholder="给这个账号起个名字" maxlength="64" />
|
||||
</el-form-item>
|
||||
<el-form-item label="账号类型" required>
|
||||
<el-select
|
||||
v-model="form.type"
|
||||
:disabled="editingId !== null"
|
||||
style="width: 100%"
|
||||
@change="form.config = ''"
|
||||
>
|
||||
<el-option
|
||||
v-for="m in metaStore.accountMetas"
|
||||
:key="m.type"
|
||||
:label="m.label"
|
||||
:value="m.type"
|
||||
/>
|
||||
</el-select>
|
||||
<div v-if="currentMeta?.description" class="muted">{{ currentMeta.description }}</div>
|
||||
</el-form-item>
|
||||
|
||||
<ConfigForm
|
||||
v-if="currentMeta"
|
||||
v-model="form.config"
|
||||
:fields="currentMeta.fields"
|
||||
/>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="saving" @click="save">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
</style>
|
||||
@@ -0,0 +1,389 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import {
|
||||
Check,
|
||||
Refresh,
|
||||
Timer as TimerIcon,
|
||||
Operation,
|
||||
User,
|
||||
Warning,
|
||||
} from '@element-plus/icons-vue'
|
||||
import { dashboardApi, timerApi } from '@/api'
|
||||
import { ApiError } from '@/api/client'
|
||||
import type { DashboardOverview } from '@/api/types'
|
||||
import { formatDateTime, formatDuration } from '@/utils/format'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const overview = ref<DashboardOverview | null>(null)
|
||||
const loading = ref(false)
|
||||
const signing = ref(false)
|
||||
|
||||
/** 本地时钟:用服务器时间校准,倒计时每秒刷新 */
|
||||
const clockOffset = ref(0)
|
||||
const nowSeconds = ref(Math.floor(Date.now() / 1000))
|
||||
let clockTimer: number | undefined
|
||||
|
||||
const urgentSecondsLeft = computed(() => {
|
||||
if (!overview.value?.urgent_timer) return null
|
||||
const timer = overview.value.urgent_timer
|
||||
return timer.last_sign + timer.sign_deration_seconds - (nowSeconds.value + clockOffset.value)
|
||||
})
|
||||
|
||||
/** 剩余时间占比(0~1),驱动进度环 */
|
||||
const urgentFraction = computed(() => {
|
||||
if (!overview.value?.urgent_timer || urgentSecondsLeft.value === null) return 0
|
||||
const total = overview.value.urgent_timer.sign_deration_seconds
|
||||
return Math.min(1, Math.max(0, urgentSecondsLeft.value / total))
|
||||
})
|
||||
|
||||
const RING_R = 52
|
||||
const ringCircumference = 2 * Math.PI * RING_R
|
||||
|
||||
const ringColor = computed(() => {
|
||||
const f = urgentFraction.value
|
||||
if (f <= 0.1) return '#ef4444'
|
||||
if (f <= 0.3) return '#f59e0b'
|
||||
return 'var(--gb-primary)'
|
||||
})
|
||||
|
||||
/** 环中心的百分比文字用深一档的颜色,浅色背景上更易读 */
|
||||
const ringTextColor = computed(() => {
|
||||
const f = urgentFraction.value
|
||||
if (f <= 0.1) return '#ef4444'
|
||||
if (f <= 0.3) return '#f59e0b'
|
||||
return 'var(--gb-primary-deep)'
|
||||
})
|
||||
|
||||
const stats = computed(() => [
|
||||
{
|
||||
label: '定时器',
|
||||
sub: `已启用 ${overview.value?.enabled_timers ?? 0} 个`,
|
||||
value: overview.value?.timer_count,
|
||||
icon: TimerIcon,
|
||||
color: '#66ccff',
|
||||
bg: 'rgb(102 204 255 / 0.14)',
|
||||
to: '/timers',
|
||||
},
|
||||
{
|
||||
label: '规则',
|
||||
sub: '触发时执行的动作',
|
||||
value: overview.value?.rule_count,
|
||||
icon: Operation,
|
||||
color: '#6366f1',
|
||||
bg: 'rgb(99 102 241 / 0.12)',
|
||||
to: '/rules',
|
||||
},
|
||||
{
|
||||
label: '账号',
|
||||
sub: '第三方凭据',
|
||||
value: overview.value?.account_count,
|
||||
icon: User,
|
||||
color: '#f59e0b',
|
||||
bg: 'rgb(245 158 11 / 0.12)',
|
||||
to: '/accounts',
|
||||
},
|
||||
{
|
||||
label: '已触发',
|
||||
sub: '规则已执行',
|
||||
value: overview.value?.triggered_count,
|
||||
icon: Warning,
|
||||
color: (overview.value?.triggered_count ?? 0) > 0 ? '#ef4444' : '#94a3b8',
|
||||
bg:
|
||||
(overview.value?.triggered_count ?? 0) > 0
|
||||
? 'rgb(239 68 68 / 0.12)'
|
||||
: 'rgb(148 163 184 / 0.12)',
|
||||
to: '/logs',
|
||||
},
|
||||
])
|
||||
|
||||
async function refresh() {
|
||||
loading.value = true
|
||||
try {
|
||||
overview.value = await dashboardApi.overview()
|
||||
clockOffset.value = overview.value.server_time - Math.floor(Date.now() / 1000)
|
||||
} catch (error) {
|
||||
ElMessage.error(error instanceof ApiError ? error.message : '加载失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function signAll() {
|
||||
signing.value = true
|
||||
try {
|
||||
const result = await timerApi.signAll()
|
||||
ElMessage.success(`签到成功,${result.signed} 个定时器重新计时`)
|
||||
await refresh()
|
||||
} catch (error) {
|
||||
ElMessage.error(error instanceof ApiError ? error.message : '签到失败')
|
||||
} finally {
|
||||
signing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
refresh()
|
||||
clockTimer = window.setInterval(() => {
|
||||
nowSeconds.value = Math.floor(Date.now() / 1000)
|
||||
}, 1000)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (clockTimer) window.clearInterval(clockTimer)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-loading="loading">
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h2>仪表盘</h2>
|
||||
<div class="muted">总览与快捷签到</div>
|
||||
</div>
|
||||
<div class="header-btns">
|
||||
<el-button :icon="Refresh" circle @click="refresh" />
|
||||
<el-button type="primary" size="large" :icon="Check" :loading="signing" @click="signAll">
|
||||
一键签到
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 已触发警示 -->
|
||||
<el-alert
|
||||
v-if="overview && overview.triggered_count > 0"
|
||||
type="error"
|
||||
show-icon
|
||||
:closable="false"
|
||||
class="urgent-alert gb-rise"
|
||||
:title="`有 ${overview.triggered_count} 个定时器已触发,关联规则已执行。请前往定时器页面重新签到。`"
|
||||
/>
|
||||
|
||||
<!-- 最近到期的定时器 -->
|
||||
<el-card v-if="overview?.urgent_timer && urgentSecondsLeft !== null" class="hero-card gb-rise">
|
||||
<div class="hero-body">
|
||||
<div class="hero-ring">
|
||||
<svg width="128" height="128" viewBox="0 0 128 128">
|
||||
<circle
|
||||
cx="64"
|
||||
cy="64"
|
||||
:r="RING_R"
|
||||
fill="none"
|
||||
stroke="var(--el-fill-color)"
|
||||
stroke-width="9"
|
||||
/>
|
||||
<circle
|
||||
cx="64"
|
||||
cy="64"
|
||||
:r="RING_R"
|
||||
fill="none"
|
||||
:stroke="ringColor"
|
||||
stroke-width="9"
|
||||
stroke-linecap="round"
|
||||
:stroke-dasharray="ringCircumference"
|
||||
:stroke-dashoffset="ringCircumference * (1 - urgentFraction)"
|
||||
transform="rotate(-90 64 64)"
|
||||
class="ring-progress"
|
||||
/>
|
||||
</svg>
|
||||
<div class="ring-label">
|
||||
<span class="ring-pct" :style="{ color: ringTextColor }">
|
||||
{{ Math.round(urgentFraction * 100) }}%
|
||||
</span>
|
||||
<span class="muted">剩余</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="hero-info">
|
||||
<div class="muted">下一个到期的定时器</div>
|
||||
<div class="hero-name">{{ overview.urgent_timer.name }}</div>
|
||||
<div class="hero-countdown" :class="{ danger: urgentFraction <= 0.1 }">
|
||||
<template v-if="urgentSecondsLeft > 0">
|
||||
<span class="countdown-num">{{ formatDuration(urgentSecondsLeft) }}</span>
|
||||
<span class="muted">后到期</span>
|
||||
</template>
|
||||
<template v-else><span class="countdown-num gb-pulse">已到期</span></template>
|
||||
</div>
|
||||
<div class="muted">上次签到:{{ formatDateTime(overview.urgent_timer.last_sign) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<!-- 统计卡片 -->
|
||||
<div class="stats-grid">
|
||||
<el-card
|
||||
v-for="(stat, i) in stats"
|
||||
:key="stat.label"
|
||||
class="stat-card gb-rise"
|
||||
:style="{ animationDelay: `${i * 0.05}s` }"
|
||||
@click="router.push(stat.to)"
|
||||
>
|
||||
<div class="stat-body">
|
||||
<div class="stat-icon" :style="{ background: stat.bg, color: stat.color }">
|
||||
<el-icon :size="20"><component :is="stat.icon" /></el-icon>
|
||||
</div>
|
||||
<div>
|
||||
<div class="stat-value">{{ stat.value ?? '-' }}</div>
|
||||
<div class="stat-label">{{ stat.label }}</div>
|
||||
<div class="muted">{{ stat.sub }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
|
||||
<!-- 最近日志 -->
|
||||
<el-card class="gb-rise" style="animation-delay: 0.15s">
|
||||
<template #header>最近执行</template>
|
||||
<el-empty
|
||||
v-if="!overview?.recent_logs?.length"
|
||||
description="暂无执行记录"
|
||||
:image-size="80"
|
||||
/>
|
||||
<el-table v-else :data="overview.recent_logs" size="default">
|
||||
<el-table-column label="时间" width="175">
|
||||
<template #default="{ row }">{{ formatDateTime(row.create_at) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="rule_name" label="规则" min-width="120" />
|
||||
<el-table-column label="结果" width="90">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.success ? 'success' : 'danger'" size="small" effect="light">
|
||||
{{ row.success ? '成功' : '失败' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="message" label="详情" min-width="240" show-overflow-tooltip />
|
||||
</el-table>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.header-btns {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.urgent-alert {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
/* ---------- 主倒计时卡 ---------- */
|
||||
|
||||
.hero-card {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.hero-body {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 32px;
|
||||
}
|
||||
|
||||
.hero-ring {
|
||||
position: relative;
|
||||
width: 128px;
|
||||
height: 128px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.ring-progress {
|
||||
transition:
|
||||
stroke-dashoffset 0.8s var(--gb-ease),
|
||||
stroke 0.5s var(--gb-ease);
|
||||
}
|
||||
|
||||
.ring-label {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.ring-pct {
|
||||
font-size: 24px;
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.02em;
|
||||
transition: color 0.5s var(--gb-ease);
|
||||
}
|
||||
|
||||
.hero-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.hero-name {
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.hero-countdown {
|
||||
color: var(--gb-primary-deep);
|
||||
}
|
||||
|
||||
.hero-countdown.danger {
|
||||
color: var(--el-color-danger);
|
||||
}
|
||||
|
||||
.countdown-num {
|
||||
font-size: 26px;
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.02em;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
/* ---------- 统计卡片 ---------- */
|
||||
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 16px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.stat-card:hover {
|
||||
transform: translateY(-3px);
|
||||
box-shadow: var(--gb-shadow-hover);
|
||||
}
|
||||
|
||||
.stat-body {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.stat-icon {
|
||||
width: 46px;
|
||||
height: 46px;
|
||||
border-radius: 13px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 26px;
|
||||
font-weight: 800;
|
||||
line-height: 1.1;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--el-text-color-regular);
|
||||
margin: 2px 0 1px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,270 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { siteApi, userApi } from '@/api'
|
||||
import { ApiError } from '@/api/client'
|
||||
import type { SiteInfo } from '@/api/types'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import LogoMark from '@/components/LogoMark.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const userStore = useUserStore()
|
||||
|
||||
const site = ref<SiteInfo | null>(null)
|
||||
const mode = ref<'login' | 'register'>('login')
|
||||
const loading = ref(false)
|
||||
|
||||
const form = ref({
|
||||
username: '',
|
||||
password: '',
|
||||
confirm: '',
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
site.value = await siteApi.info()
|
||||
// 全新部署引导创建第一个账号
|
||||
if (site.value.need_initial_user) {
|
||||
mode.value = 'register'
|
||||
}
|
||||
} catch {
|
||||
// 站点信息拉取失败不阻塞登录
|
||||
}
|
||||
})
|
||||
|
||||
async function submit() {
|
||||
if (!form.value.username || !form.value.password) {
|
||||
ElMessage.warning('请输入用户名和密码')
|
||||
return
|
||||
}
|
||||
if (mode.value === 'register' && form.value.password !== form.value.confirm) {
|
||||
ElMessage.warning('两次输入的密码不一致')
|
||||
return
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
if (mode.value === 'register') {
|
||||
await userApi.register(form.value.username, form.value.password)
|
||||
ElMessage.success('注册成功')
|
||||
} else {
|
||||
await userApi.login(form.value.username, form.value.password)
|
||||
}
|
||||
await userStore.fetchUser()
|
||||
const redirect = typeof route.query.redirect === 'string' ? route.query.redirect : '/dashboard'
|
||||
router.push(redirect)
|
||||
} catch (error) {
|
||||
ElMessage.error(error instanceof ApiError ? error.message : '操作失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="login-page">
|
||||
<!-- 左侧:项目介绍 -->
|
||||
<div class="intro">
|
||||
<div class="intro-inner gb-rise">
|
||||
<div class="intro-mark"><LogoMark :size="28" /></div>
|
||||
<h1 class="intro-title">goodBaby</h1>
|
||||
<p class="intro-slogan">摇篮系统 · Dead Man's Switch</p>
|
||||
<p class="intro-desc">
|
||||
设定签到周期并定期签到;<br />
|
||||
超时未签到时,系统会自动执行你预设的规则。
|
||||
</p>
|
||||
<ul class="intro-points">
|
||||
<li>到期前通过钉钉机器人提醒签到</li>
|
||||
<li>支持发送邮件、QQ、钉钉消息、B 站动态</li>
|
||||
<li>支持自动公开 GitHub 仓库</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 右侧:登录表单 -->
|
||||
<div class="panel">
|
||||
<div class="panel-card gb-rise">
|
||||
<h2 class="panel-title">
|
||||
{{ site?.need_initial_user ? '创建初始账号' : mode === 'login' ? '登录' : '注册' }}
|
||||
</h2>
|
||||
<p class="panel-sub">
|
||||
{{
|
||||
site?.need_initial_user
|
||||
? '首次使用,请先创建管理员账号'
|
||||
: mode === 'login'
|
||||
? '登录你的 goodBaby 账号'
|
||||
: '创建一个新账号'
|
||||
}}
|
||||
</p>
|
||||
|
||||
<el-form label-position="top" size="large" @keyup.enter="submit">
|
||||
<el-form-item label="用户名">
|
||||
<el-input v-model="form.username" placeholder="2-32 个字符" autofocus />
|
||||
</el-form-item>
|
||||
<el-form-item label="密码">
|
||||
<el-input
|
||||
v-model="form.password"
|
||||
type="password"
|
||||
show-password
|
||||
placeholder="6-64 个字符"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item v-if="mode === 'register'" label="确认密码">
|
||||
<el-input v-model="form.confirm" type="password" show-password placeholder="再输入一次" />
|
||||
</el-form-item>
|
||||
|
||||
<el-button type="primary" size="large" class="submit-btn" :loading="loading" @click="submit">
|
||||
{{ mode === 'login' ? '登 录' : '注 册' }}
|
||||
</el-button>
|
||||
</el-form>
|
||||
|
||||
<div v-if="site?.enable_registry && !site?.need_initial_user" class="switch-mode">
|
||||
<el-link v-if="mode === 'login'" type="primary" :underline="false" @click="mode = 'register'">
|
||||
没有账号?注册
|
||||
</el-link>
|
||||
<el-link v-else type="primary" :underline="false" @click="mode = 'login'">
|
||||
已有账号?登录
|
||||
</el-link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.login-page {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
/* ---------- 左侧理念区 ---------- */
|
||||
|
||||
.intro {
|
||||
flex: 1.15;
|
||||
background:
|
||||
radial-gradient(ellipse at 20% 20%, rgb(102 204 255 / 0.16) 0%, transparent 55%),
|
||||
radial-gradient(ellipse at 85% 80%, rgb(102 204 255 / 0.12) 0%, transparent 50%),
|
||||
linear-gradient(160deg, #14161c 0%, #1b1f27 100%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 48px;
|
||||
}
|
||||
|
||||
.intro-inner {
|
||||
max-width: 420px;
|
||||
}
|
||||
|
||||
.intro-mark {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 16px;
|
||||
background: linear-gradient(135deg, var(--gb-primary) 0%, #3fa9e0 100%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #0b2b3d;
|
||||
box-shadow: 0 8px 24px rgb(102 204 255 / 0.5);
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.intro-title {
|
||||
margin: 0;
|
||||
color: #fff;
|
||||
font-size: 34px;
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.intro-slogan {
|
||||
margin: 10px 0 20px;
|
||||
color: #66ccff;
|
||||
font-size: 17px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.intro-desc {
|
||||
margin: 0 0 26px;
|
||||
color: rgb(255 255 255 / 0.66);
|
||||
font-size: 15px;
|
||||
line-height: 2;
|
||||
}
|
||||
|
||||
.intro-points {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.intro-points li {
|
||||
color: rgb(255 255 255 / 0.5);
|
||||
font-size: 13px;
|
||||
padding-left: 20px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.intro-points li::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 6px;
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
background: var(--gb-primary);
|
||||
}
|
||||
|
||||
/* ---------- 右侧表单区 ---------- */
|
||||
|
||||
.panel {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--gb-bg);
|
||||
padding: 32px;
|
||||
}
|
||||
|
||||
.panel-card {
|
||||
width: 100%;
|
||||
max-width: 360px;
|
||||
}
|
||||
|
||||
.panel-title {
|
||||
margin: 0;
|
||||
font-size: 26px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.02em;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
|
||||
.panel-sub {
|
||||
margin: 8px 0 28px;
|
||||
color: var(--el-text-color-secondary);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.submit-btn {
|
||||
width: 100%;
|
||||
margin-top: 8px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.1em;
|
||||
}
|
||||
|
||||
.switch-mode {
|
||||
text-align: center;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
/* 窄屏时隐藏左侧介绍 */
|
||||
@media (max-width: 860px) {
|
||||
.intro {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,136 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref, watch } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Delete, Refresh } from '@element-plus/icons-vue'
|
||||
import { logApi } from '@/api'
|
||||
import { ApiError } from '@/api/client'
|
||||
import type { ExecutionLog } from '@/api/types'
|
||||
import { formatDateTime } from '@/utils/format'
|
||||
|
||||
const logs = ref<ExecutionLog[]>([])
|
||||
const total = ref(0)
|
||||
const page = ref(1)
|
||||
const pageSize = ref(20)
|
||||
const successFilter = ref<'all' | 'true' | 'false'>('all')
|
||||
const loading = ref(false)
|
||||
|
||||
const triggerLabels: Record<string, string> = {
|
||||
timer: '定时触发',
|
||||
manual: '手动测试',
|
||||
remind: '提醒',
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
loading.value = true
|
||||
try {
|
||||
const result = await logApi.list({
|
||||
page: page.value,
|
||||
page_size: pageSize.value,
|
||||
success: successFilter.value === 'all' ? undefined : successFilter.value,
|
||||
})
|
||||
logs.value = result.items
|
||||
total.value = result.total
|
||||
} catch (error) {
|
||||
ElMessage.error(error instanceof ApiError ? error.message : '加载失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function clearAll() {
|
||||
try {
|
||||
await ElMessageBox.confirm('确定清空所有执行日志吗?该操作不可恢复。', '清空日志', {
|
||||
type: 'warning',
|
||||
confirmButtonText: '清空',
|
||||
confirmButtonClass: 'el-button--danger',
|
||||
cancelButtonText: '取消',
|
||||
})
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await logApi.clear()
|
||||
ElMessage.success('已清空')
|
||||
page.value = 1
|
||||
await refresh()
|
||||
} catch (error) {
|
||||
ElMessage.error(error instanceof ApiError ? error.message : '清空失败')
|
||||
}
|
||||
}
|
||||
|
||||
watch([page, pageSize, successFilter], refresh)
|
||||
|
||||
onMounted(refresh)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-loading="loading">
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h2>执行日志</h2>
|
||||
<div class="muted">规则执行与提醒的历史记录</div>
|
||||
</div>
|
||||
<div class="header-tools">
|
||||
<el-select v-model="successFilter" style="width: 140px">
|
||||
<el-option label="全部" value="all" />
|
||||
<el-option label="仅成功" value="true" />
|
||||
<el-option label="仅失败" value="false" />
|
||||
</el-select>
|
||||
<el-button :icon="Refresh" @click="refresh">刷新</el-button>
|
||||
<el-button type="danger" plain :icon="Delete" @click="clearAll">清空</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-empty v-if="!loading && logs.length === 0" description="暂无执行记录" />
|
||||
|
||||
<template v-else>
|
||||
<el-card class="gb-rise">
|
||||
<el-table :data="logs">
|
||||
<el-table-column label="时间" width="175">
|
||||
<template #default="{ row }">{{ formatDateTime(row.create_at) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="rule_name" label="规则 / 定时器" min-width="130" />
|
||||
<el-table-column prop="rule_type" label="类型" width="150" />
|
||||
<el-table-column label="触发方式" width="110">
|
||||
<template #default="{ row }">
|
||||
{{ triggerLabels[row.trigger] ?? row.trigger }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="结果" width="90">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.success ? 'success' : 'danger'" size="small">
|
||||
{{ row.success ? '成功' : '失败' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="message" label="详情" min-width="260" show-overflow-tooltip />
|
||||
</el-table>
|
||||
</el-card>
|
||||
|
||||
<div class="pager">
|
||||
<el-pagination
|
||||
v-model:current-page="page"
|
||||
v-model:page-size="pageSize"
|
||||
:total="total"
|
||||
:page-sizes="[20, 50, 100]"
|
||||
layout="total, sizes, prev, pager, next"
|
||||
background
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.header-tools {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.pager {
|
||||
margin-top: 16px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,357 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Plus, VideoPlay } from '@element-plus/icons-vue'
|
||||
import { accountApi, ruleApi, timerApi } from '@/api'
|
||||
import { ApiError } from '@/api/client'
|
||||
import type { Account, Rule, Timer } from '@/api/types'
|
||||
import { useMetaStore } from '@/stores/meta'
|
||||
import { formatDateTime } from '@/utils/format'
|
||||
import ConfigForm from '@/components/ConfigForm.vue'
|
||||
|
||||
const metaStore = useMetaStore()
|
||||
|
||||
const rules = ref<Rule[]>([])
|
||||
const timers = ref<Timer[]>([])
|
||||
const accounts = ref<Account[]>([])
|
||||
const loading = ref(false)
|
||||
const testingId = ref<number | null>(null)
|
||||
const filterTimerId = ref<number | undefined>(undefined)
|
||||
|
||||
// ---- 创建 / 编辑对话框 ----
|
||||
const dialogVisible = ref(false)
|
||||
const saving = ref(false)
|
||||
const editingId = ref<number | null>(null)
|
||||
|
||||
const form = ref<{
|
||||
name: string
|
||||
type: string
|
||||
timer_id: number
|
||||
account_id: number | undefined
|
||||
enabled: boolean
|
||||
config_json: string
|
||||
}>({
|
||||
name: '',
|
||||
type: '',
|
||||
timer_id: 0,
|
||||
account_id: undefined,
|
||||
enabled: true,
|
||||
config_json: '',
|
||||
})
|
||||
|
||||
const dialogTitle = computed(() => (editingId.value === null ? '创建规则' : '编辑规则'))
|
||||
const currentMeta = computed(() => metaStore.ruleMeta(form.value.type))
|
||||
|
||||
/** 当前规则类型可用的账号(按类型过滤) */
|
||||
const availableAccounts = computed(() => {
|
||||
const requiredType = currentMeta.value?.account_type
|
||||
if (!requiredType) return []
|
||||
return accounts.value.filter((a) => a.type === requiredType)
|
||||
})
|
||||
|
||||
const filteredRules = computed(() => {
|
||||
if (!filterTimerId.value) return rules.value
|
||||
return rules.value.filter((r) => r.timer_id === filterTimerId.value)
|
||||
})
|
||||
|
||||
function timerName(id: number): string {
|
||||
return timers.value.find((t) => t.id === id)?.name ?? `#${id}`
|
||||
}
|
||||
|
||||
function accountName(id: number): string {
|
||||
if (!id) return '-'
|
||||
return accounts.value.find((a) => a.id === id)?.name ?? `#${id}`
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
loading.value = true
|
||||
try {
|
||||
const [ruleList, timerList, accountList] = await Promise.all([
|
||||
ruleApi.list(),
|
||||
timerApi.list(),
|
||||
accountApi.list(),
|
||||
])
|
||||
rules.value = ruleList
|
||||
timers.value = timerList
|
||||
accounts.value = accountList
|
||||
} catch (error) {
|
||||
ElMessage.error(error instanceof ApiError ? error.message : '加载失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
if (timers.value.length === 0) {
|
||||
ElMessage.warning('请先在“定时器”页面创建一个定时器')
|
||||
return
|
||||
}
|
||||
editingId.value = null
|
||||
form.value = {
|
||||
name: '',
|
||||
type: metaStore.ruleMetas[0]?.type ?? '',
|
||||
timer_id: timers.value[0]?.id ?? 0,
|
||||
account_id: undefined,
|
||||
enabled: true,
|
||||
config_json: '',
|
||||
}
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
function openEdit(rule: Rule) {
|
||||
editingId.value = rule.id
|
||||
form.value = {
|
||||
name: rule.name,
|
||||
type: rule.type,
|
||||
timer_id: rule.timer_id,
|
||||
account_id: rule.account_id || undefined,
|
||||
enabled: rule.enabled,
|
||||
config_json: rule.config_json,
|
||||
}
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
function onTypeChange() {
|
||||
form.value.config_json = ''
|
||||
form.value.account_id = undefined
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (!form.value.name) {
|
||||
ElMessage.warning('请填写规则名称')
|
||||
return
|
||||
}
|
||||
if (currentMeta.value?.account_type && !form.value.account_id) {
|
||||
ElMessage.warning(`该规则类型需要关联一个「${metaStore.accountLabel(currentMeta.value.account_type)}」账号`)
|
||||
return
|
||||
}
|
||||
|
||||
const body = {
|
||||
name: form.value.name,
|
||||
type: form.value.type,
|
||||
timer_id: form.value.timer_id,
|
||||
account_id: form.value.account_id ?? 0,
|
||||
enabled: form.value.enabled,
|
||||
config_json: form.value.config_json,
|
||||
}
|
||||
|
||||
saving.value = true
|
||||
try {
|
||||
if (editingId.value === null) {
|
||||
await ruleApi.create(body)
|
||||
ElMessage.success('规则已创建')
|
||||
} else {
|
||||
await ruleApi.update(editingId.value, body)
|
||||
ElMessage.success('规则已更新')
|
||||
}
|
||||
dialogVisible.value = false
|
||||
await refresh()
|
||||
} catch (error) {
|
||||
ElMessage.error(error instanceof ApiError ? error.message : '保存失败')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleEnabled(rule: Rule) {
|
||||
try {
|
||||
await ruleApi.update(rule.id, {
|
||||
name: rule.name,
|
||||
type: rule.type,
|
||||
timer_id: rule.timer_id,
|
||||
account_id: rule.account_id,
|
||||
enabled: rule.enabled,
|
||||
config_json: rule.config_json,
|
||||
})
|
||||
ElMessage.success(rule.enabled ? '已启用' : '已停用')
|
||||
} catch (error) {
|
||||
rule.enabled = !rule.enabled
|
||||
ElMessage.error(error instanceof ApiError ? error.message : '操作失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function test(rule: Rule) {
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
'测试会真实执行该规则(发送消息 / 修改仓库等),确定继续吗?',
|
||||
`测试规则「${rule.name}」`,
|
||||
{ type: 'warning', confirmButtonText: '执行', cancelButtonText: '取消' },
|
||||
)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
testingId.value = rule.id
|
||||
try {
|
||||
await ruleApi.test(rule.id)
|
||||
ElMessage.success('执行成功,详情见执行日志')
|
||||
} catch (error) {
|
||||
ElMessage.error(error instanceof ApiError ? error.message : '执行失败')
|
||||
} finally {
|
||||
testingId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(rule: Rule) {
|
||||
try {
|
||||
await ElMessageBox.confirm(`确定删除规则「${rule.name}」吗?`, '删除规则', {
|
||||
type: 'warning',
|
||||
confirmButtonText: '删除',
|
||||
confirmButtonClass: 'el-button--danger',
|
||||
cancelButtonText: '取消',
|
||||
})
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await ruleApi.remove(rule.id)
|
||||
ElMessage.success('已删除')
|
||||
await refresh()
|
||||
} catch (error) {
|
||||
ElMessage.error(error instanceof ApiError ? error.message : '删除失败')
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await metaStore.ensureLoaded()
|
||||
await refresh()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-loading="loading">
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h2>规则</h2>
|
||||
<div class="muted">定时器到期时要执行的动作:发送邮件、QQ / 钉钉消息、B 站动态,或公开 GitHub 仓库</div>
|
||||
</div>
|
||||
<div class="header-tools">
|
||||
<el-select
|
||||
v-model="filterTimerId"
|
||||
placeholder="按定时器筛选"
|
||||
clearable
|
||||
style="width: 200px"
|
||||
>
|
||||
<el-option v-for="t in timers" :key="t.id" :label="t.name" :value="t.id" />
|
||||
</el-select>
|
||||
<el-button type="primary" :icon="Plus" @click="openCreate">创建规则</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-empty v-if="!loading && filteredRules.length === 0" description="还没有规则">
|
||||
<el-button type="primary" @click="openCreate">创建规则</el-button>
|
||||
</el-empty>
|
||||
|
||||
<el-card v-else class="table-card gb-rise">
|
||||
<el-table :data="filteredRules">
|
||||
<el-table-column prop="name" label="名称" min-width="130" />
|
||||
<el-table-column label="类型" width="160">
|
||||
<template #default="{ row }">
|
||||
<el-tag>{{ metaStore.ruleLabel(row.type) }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="定时器" min-width="120">
|
||||
<template #default="{ row }">{{ timerName(row.timer_id) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="账号" min-width="120">
|
||||
<template #default="{ row }">{{ accountName(row.account_id) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="创建时间" width="170">
|
||||
<template #default="{ row }">{{ formatDateTime(row.create_at) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="启用" width="80">
|
||||
<template #default="{ row }">
|
||||
<el-switch v-model="row.enabled" size="small" @change="toggleEnabled(row)" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="240">
|
||||
<template #default="{ row }">
|
||||
<el-button
|
||||
size="small"
|
||||
:icon="VideoPlay"
|
||||
:loading="testingId === row.id"
|
||||
@click="test(row)"
|
||||
>
|
||||
测试
|
||||
</el-button>
|
||||
<el-button size="small" @click="openEdit(row)">编辑</el-button>
|
||||
<el-button size="small" type="danger" plain @click="remove(row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
|
||||
<!-- 创建/编辑对话框 -->
|
||||
<el-dialog v-model="dialogVisible" :title="dialogTitle" width="560px">
|
||||
<el-form label-width="110px">
|
||||
<el-form-item label="规则名称" required>
|
||||
<el-input v-model="form.name" placeholder="给这条规则起个名字" maxlength="64" />
|
||||
</el-form-item>
|
||||
<el-form-item label="规则类型" required>
|
||||
<el-select
|
||||
v-model="form.type"
|
||||
:disabled="editingId !== null"
|
||||
style="width: 100%"
|
||||
@change="onTypeChange"
|
||||
>
|
||||
<el-option
|
||||
v-for="m in metaStore.ruleMetas"
|
||||
:key="m.type"
|
||||
:label="m.label"
|
||||
:value="m.type"
|
||||
/>
|
||||
</el-select>
|
||||
<div v-if="currentMeta?.description" class="muted">{{ currentMeta.description }}</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="关联定时器" required>
|
||||
<el-select v-model="form.timer_id" style="width: 100%">
|
||||
<el-option v-for="t in timers" :key="t.id" :label="t.name" :value="t.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item v-if="currentMeta?.account_type" label="关联账号" required>
|
||||
<el-select
|
||||
v-model="form.account_id"
|
||||
style="width: 100%"
|
||||
:placeholder="availableAccounts.length ? '选择账号' : '没有可用账号,请先到“账号”页面添加'"
|
||||
>
|
||||
<el-option
|
||||
v-for="a in availableAccounts"
|
||||
:key="a.id"
|
||||
:label="a.name"
|
||||
:value="a.id"
|
||||
/>
|
||||
</el-select>
|
||||
<div class="muted">
|
||||
需要「{{ metaStore.accountLabel(currentMeta.account_type) }}」类型的账号
|
||||
</div>
|
||||
</el-form-item>
|
||||
|
||||
<ConfigForm
|
||||
v-if="currentMeta"
|
||||
v-model="form.config_json"
|
||||
:fields="currentMeta.fields"
|
||||
/>
|
||||
|
||||
<el-form-item label="启用">
|
||||
<el-switch v-model="form.enabled" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="saving" @click="save">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.tip {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.header-tools {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,175 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { userApi } from '@/api'
|
||||
import { ApiError } from '@/api/client'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import { formatDateTime } from '@/utils/format'
|
||||
|
||||
const userStore = useUserStore()
|
||||
|
||||
// ---- 钉钉提醒配置 ----
|
||||
const notifySaving = ref(false)
|
||||
const dingtalk = ref({
|
||||
enabled: false,
|
||||
access_token: '',
|
||||
secret: '',
|
||||
})
|
||||
|
||||
function loadNotifyFromUser() {
|
||||
const raw = userStore.user?.dingtalk_config
|
||||
if (!raw) {
|
||||
dingtalk.value = { enabled: false, access_token: '', secret: '' }
|
||||
return
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as { access_token?: string; secret?: string }
|
||||
dingtalk.value = {
|
||||
enabled: true,
|
||||
access_token: parsed.access_token ?? '',
|
||||
secret: parsed.secret ?? '',
|
||||
}
|
||||
} catch {
|
||||
dingtalk.value = { enabled: false, access_token: '', secret: '' }
|
||||
}
|
||||
}
|
||||
|
||||
async function saveNotify() {
|
||||
if (dingtalk.value.enabled && !dingtalk.value.access_token) {
|
||||
ElMessage.warning('请填写钉钉机器人的 Access Token')
|
||||
return
|
||||
}
|
||||
|
||||
notifySaving.value = true
|
||||
try {
|
||||
const payload = dingtalk.value.enabled
|
||||
? JSON.stringify({
|
||||
access_token: dingtalk.value.access_token,
|
||||
secret: dingtalk.value.secret,
|
||||
})
|
||||
: null
|
||||
await userApi.updateNotify(payload)
|
||||
ElMessage.success('提醒配置已保存')
|
||||
await userStore.fetchUser()
|
||||
} catch (error) {
|
||||
ElMessage.error(error instanceof ApiError ? error.message : '保存失败')
|
||||
} finally {
|
||||
notifySaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 修改密码 ----
|
||||
const passwordSaving = ref(false)
|
||||
const passwordForm = ref({
|
||||
old: '',
|
||||
new1: '',
|
||||
new2: '',
|
||||
})
|
||||
|
||||
async function changePassword() {
|
||||
if (!passwordForm.value.old || !passwordForm.value.new1) {
|
||||
ElMessage.warning('请填写完整')
|
||||
return
|
||||
}
|
||||
if (passwordForm.value.new1 !== passwordForm.value.new2) {
|
||||
ElMessage.warning('两次输入的新密码不一致')
|
||||
return
|
||||
}
|
||||
|
||||
passwordSaving.value = true
|
||||
try {
|
||||
await userApi.changePassword(passwordForm.value.old, passwordForm.value.new1)
|
||||
ElMessage.success('密码修改成功')
|
||||
passwordForm.value = { old: '', new1: '', new2: '' }
|
||||
} catch (error) {
|
||||
ElMessage.error(error instanceof ApiError ? error.message : '修改失败')
|
||||
} finally {
|
||||
passwordSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
if (!userStore.user) {
|
||||
await userStore.fetchUser()
|
||||
}
|
||||
loadNotifyFromUser()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="settings">
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h2>设置</h2>
|
||||
<div class="muted">提醒渠道与账号安全</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-card class="section">
|
||||
<template #header>账号信息</template>
|
||||
<el-descriptions :column="2">
|
||||
<el-descriptions-item label="用户名">
|
||||
{{ userStore.user?.username }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="注册时间">
|
||||
{{ formatDateTime(userStore.user?.create_at ?? 0) }}
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</el-card>
|
||||
|
||||
<el-card class="section">
|
||||
<template #header>提醒渠道(钉钉机器人)</template>
|
||||
<p class="muted">
|
||||
定时器临近到期时,通过钉钉自定义机器人提醒你签到。在钉钉群里添加“自定义机器人”,把
|
||||
Webhook 中的 access_token 填到下面;如果机器人开启了“加签”,还需要填写 Secret。
|
||||
</p>
|
||||
<el-form label-width="130px" style="max-width: 560px">
|
||||
<el-form-item label="启用钉钉提醒">
|
||||
<el-switch v-model="dingtalk.enabled" />
|
||||
</el-form-item>
|
||||
<template v-if="dingtalk.enabled">
|
||||
<el-form-item label="Access Token" required>
|
||||
<el-input v-model="dingtalk.access_token" show-password type="password" />
|
||||
</el-form-item>
|
||||
<el-form-item label="加签 Secret">
|
||||
<el-input v-model="dingtalk.secret" show-password type="password" />
|
||||
</el-form-item>
|
||||
</template>
|
||||
<el-form-item>
|
||||
<el-button type="primary" :loading="notifySaving" @click="saveNotify">保存</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
|
||||
<el-card class="section">
|
||||
<template #header>修改密码</template>
|
||||
<el-form label-width="130px" style="max-width: 560px">
|
||||
<el-form-item label="原密码" required>
|
||||
<el-input v-model="passwordForm.old" type="password" show-password />
|
||||
</el-form-item>
|
||||
<el-form-item label="新密码" required>
|
||||
<el-input
|
||||
v-model="passwordForm.new1"
|
||||
type="password"
|
||||
show-password
|
||||
placeholder="6-64 个字符"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="确认新密码" required>
|
||||
<el-input v-model="passwordForm.new2" type="password" show-password />
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" :loading="passwordSaving" @click="changePassword">
|
||||
修改密码
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.section {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,438 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Check, Plus, VideoPlay } from '@element-plus/icons-vue'
|
||||
import { timerApi } from '@/api'
|
||||
import { ApiError } from '@/api/client'
|
||||
import type { Timer, TimerRequest } from '@/api/types'
|
||||
import { durationPresets, formatDateTime, formatDuration } from '@/utils/format'
|
||||
|
||||
const timers = ref<Timer[]>([])
|
||||
const loading = ref(false)
|
||||
|
||||
const nowSeconds = ref(Math.floor(Date.now() / 1000))
|
||||
let ticker: number | undefined
|
||||
|
||||
// ---- 创建 / 编辑对话框 ----
|
||||
const dialogVisible = ref(false)
|
||||
const saving = ref(false)
|
||||
const editingId = ref<number | null>(null)
|
||||
|
||||
interface TimerForm {
|
||||
name: string
|
||||
description: string
|
||||
enabled: boolean
|
||||
durationDays: number
|
||||
remindHours: number
|
||||
}
|
||||
|
||||
const emptyForm = (): TimerForm => ({
|
||||
name: '',
|
||||
description: '',
|
||||
enabled: true,
|
||||
durationDays: 7,
|
||||
remindHours: 24,
|
||||
})
|
||||
|
||||
const form = ref<TimerForm>(emptyForm())
|
||||
|
||||
const dialogTitle = computed(() => (editingId.value === null ? '创建定时器' : '编辑定时器'))
|
||||
|
||||
function secondsLeft(timer: Timer): number {
|
||||
return timer.last_sign + timer.sign_deration_seconds - nowSeconds.value
|
||||
}
|
||||
|
||||
/** 剩余时间占比(0~1),驱动卡片进度条 */
|
||||
function fractionLeft(timer: Timer): number {
|
||||
return Math.min(1, Math.max(0, secondsLeft(timer) / timer.sign_deration_seconds))
|
||||
}
|
||||
|
||||
function barColor(timer: Timer): string {
|
||||
if (!timer.enabled) return 'var(--el-fill-color-dark)'
|
||||
const f = fractionLeft(timer)
|
||||
if (timer.triggered || f <= 0.1) return '#ef4444'
|
||||
if (f <= 0.3) return '#f59e0b'
|
||||
return 'var(--gb-primary)'
|
||||
}
|
||||
|
||||
function statusOf(timer: Timer): { label: string; type: 'success' | 'warning' | 'danger' | 'info' } {
|
||||
if (!timer.enabled) return { label: '已暂停', type: 'info' }
|
||||
if (timer.triggered) return { label: '已触发', type: 'danger' }
|
||||
const left = secondsLeft(timer)
|
||||
if (left <= 0) return { label: '即将触发', type: 'danger' }
|
||||
if (left <= timer.remind_time_seconds) return { label: '临近到期', type: 'warning' }
|
||||
return { label: '正常', type: 'success' }
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
loading.value = true
|
||||
try {
|
||||
timers.value = await timerApi.list()
|
||||
} catch (error) {
|
||||
ElMessage.error(error instanceof ApiError ? error.message : '加载失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
editingId.value = null
|
||||
form.value = emptyForm()
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
function openEdit(timer: Timer) {
|
||||
editingId.value = timer.id
|
||||
form.value = {
|
||||
name: timer.name,
|
||||
description: timer.description,
|
||||
enabled: timer.enabled,
|
||||
durationDays: timer.sign_deration_seconds / 86400,
|
||||
remindHours: timer.remind_time_seconds / 3600,
|
||||
}
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
async function save() {
|
||||
const body: TimerRequest = {
|
||||
name: form.value.name,
|
||||
description: form.value.description,
|
||||
enabled: form.value.enabled,
|
||||
sign_deration_seconds: Math.round(form.value.durationDays * 86400),
|
||||
remind_time_seconds: Math.round(form.value.remindHours * 3600),
|
||||
}
|
||||
|
||||
saving.value = true
|
||||
try {
|
||||
if (editingId.value === null) {
|
||||
await timerApi.create(body)
|
||||
ElMessage.success('定时器已创建,从现在开始计时')
|
||||
} else {
|
||||
await timerApi.update(editingId.value, body)
|
||||
ElMessage.success('定时器已更新')
|
||||
}
|
||||
dialogVisible.value = false
|
||||
await refresh()
|
||||
} catch (error) {
|
||||
ElMessage.error(error instanceof ApiError ? error.message : '保存失败')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function sign(timer: Timer) {
|
||||
try {
|
||||
await timerApi.sign(timer.id)
|
||||
ElMessage.success(`「${timer.name}」签到成功,重新计时`)
|
||||
await refresh()
|
||||
} catch (error) {
|
||||
ElMessage.error(error instanceof ApiError ? error.message : '签到失败')
|
||||
}
|
||||
}
|
||||
|
||||
const triggeringId = ref<number | null>(null)
|
||||
|
||||
/** 手动触发:真实执行该定时器下所有启用的规则,用于调试 */
|
||||
async function trigger(timer: Timer) {
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
'将立即执行该定时器下所有启用的规则(真实发送消息 / 修改仓库),不影响签到状态。确定继续吗?',
|
||||
`手动触发「${timer.name}」`,
|
||||
{ type: 'warning', confirmButtonText: '执行', cancelButtonText: '取消' },
|
||||
)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
triggeringId.value = timer.id
|
||||
try {
|
||||
const result = await timerApi.trigger(timer.id)
|
||||
if (result.failed.length === 0) {
|
||||
ElMessage.success(`已执行 ${result.total} 条规则,全部成功`)
|
||||
} else {
|
||||
ElMessage.warning(
|
||||
`已执行 ${result.total} 条规则,${result.failed.length} 条失败,详情见执行日志`,
|
||||
)
|
||||
}
|
||||
} catch (error) {
|
||||
ElMessage.error(error instanceof ApiError ? error.message : '触发失败')
|
||||
} finally {
|
||||
triggeringId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleEnabled(timer: Timer) {
|
||||
try {
|
||||
await timerApi.update(timer.id, {
|
||||
name: timer.name,
|
||||
description: timer.description,
|
||||
enabled: timer.enabled,
|
||||
sign_deration_seconds: timer.sign_deration_seconds,
|
||||
remind_time_seconds: timer.remind_time_seconds,
|
||||
})
|
||||
ElMessage.success(timer.enabled ? '已启用' : '已暂停')
|
||||
} catch (error) {
|
||||
timer.enabled = !timer.enabled
|
||||
ElMessage.error(error instanceof ApiError ? error.message : '操作失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(timer: Timer) {
|
||||
try {
|
||||
const affected = await timerApi.checkDelete(timer.id)
|
||||
const warning =
|
||||
affected.length > 0
|
||||
? `删除后,关联的 ${affected.length} 条规则也会被一并删除:${affected
|
||||
.map((r) => r.name)
|
||||
.join('、')}`
|
||||
: '该定时器下没有关联规则。'
|
||||
|
||||
await ElMessageBox.confirm(warning, `删除定时器「${timer.name}」?`, {
|
||||
type: 'warning',
|
||||
confirmButtonText: '删除',
|
||||
confirmButtonClass: 'el-button--danger',
|
||||
cancelButtonText: '取消',
|
||||
})
|
||||
} catch {
|
||||
return // 用户取消
|
||||
}
|
||||
|
||||
try {
|
||||
await timerApi.remove(timer.id)
|
||||
ElMessage.success('已删除')
|
||||
await refresh()
|
||||
} catch (error) {
|
||||
ElMessage.error(error instanceof ApiError ? error.message : '删除失败')
|
||||
}
|
||||
}
|
||||
|
||||
function applyPreset(seconds: number) {
|
||||
form.value.durationDays = seconds / 86400
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
refresh()
|
||||
ticker = window.setInterval(() => {
|
||||
nowSeconds.value = Math.floor(Date.now() / 1000)
|
||||
}, 1000)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (ticker) window.clearInterval(ticker)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-loading="loading">
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h2>定时器</h2>
|
||||
<div class="muted">在签到周期内完成签到;超时未签到时将执行关联的规则</div>
|
||||
</div>
|
||||
<el-button type="primary" :icon="Plus" @click="openCreate">创建定时器</el-button>
|
||||
</div>
|
||||
|
||||
<el-empty v-if="!loading && timers.length === 0" description="还没有定时器">
|
||||
<el-button type="primary" @click="openCreate">创建定时器</el-button>
|
||||
</el-empty>
|
||||
|
||||
<div class="timer-grid">
|
||||
<el-card
|
||||
v-for="(timer, i) in timers"
|
||||
:key="timer.id"
|
||||
class="timer-card gb-rise"
|
||||
:style="{ animationDelay: `${i * 0.05}s` }"
|
||||
>
|
||||
<div class="timer-head">
|
||||
<div class="timer-name">{{ timer.name }}</div>
|
||||
<el-tag :type="statusOf(timer).type" size="small" effect="light">
|
||||
{{ statusOf(timer).label }}
|
||||
</el-tag>
|
||||
</div>
|
||||
|
||||
<div class="muted timer-desc">{{ timer.description || ' ' }}</div>
|
||||
|
||||
<div
|
||||
class="timer-countdown"
|
||||
:class="{ danger: timer.triggered || secondsLeft(timer) <= timer.remind_time_seconds }"
|
||||
>
|
||||
<template v-if="timer.triggered">已触发,规则已执行;请重新签到</template>
|
||||
<template v-else-if="!timer.enabled">已暂停</template>
|
||||
<template v-else-if="secondsLeft(timer) > 0">
|
||||
{{ formatDuration(secondsLeft(timer)) }} 后到期
|
||||
</template>
|
||||
<template v-else><span class="gb-pulse">已到期,即将执行规则</span></template>
|
||||
</div>
|
||||
|
||||
<!-- 剩余时间进度条 -->
|
||||
<div class="timer-bar">
|
||||
<div
|
||||
class="timer-bar-fill"
|
||||
:style="{
|
||||
width: `${fractionLeft(timer) * 100}%`,
|
||||
background: barColor(timer),
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="muted timer-meta">
|
||||
<span>周期 {{ formatDuration(timer.sign_deration_seconds) }}</span>
|
||||
<span>提前 {{ formatDuration(timer.remind_time_seconds) }} 提醒</span>
|
||||
<span>上次 {{ formatDateTime(timer.last_sign) }}</span>
|
||||
</div>
|
||||
|
||||
<div class="timer-actions">
|
||||
<el-button type="primary" :icon="Check" size="small" @click="sign(timer)">
|
||||
签到
|
||||
</el-button>
|
||||
<el-button
|
||||
size="small"
|
||||
:icon="VideoPlay"
|
||||
:loading="triggeringId === timer.id"
|
||||
title="手动执行该定时器下的所有规则,用于调试"
|
||||
@click="trigger(timer)"
|
||||
>
|
||||
触发
|
||||
</el-button>
|
||||
<el-button size="small" @click="openEdit(timer)">编辑</el-button>
|
||||
<el-button size="small" type="danger" plain @click="remove(timer)">删除</el-button>
|
||||
<el-switch
|
||||
v-model="timer.enabled"
|
||||
size="small"
|
||||
class="timer-switch"
|
||||
@change="toggleEnabled(timer)"
|
||||
/>
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
|
||||
<!-- 创建/编辑对话框 -->
|
||||
<el-dialog v-model="dialogVisible" :title="dialogTitle" width="480px">
|
||||
<el-form label-width="90px">
|
||||
<el-form-item label="名称" required>
|
||||
<el-input v-model="form.name" placeholder="例如:每周报平安" maxlength="64" />
|
||||
</el-form-item>
|
||||
<el-form-item label="描述">
|
||||
<el-input v-model="form.description" type="textarea" :rows="2" placeholder="备注(可选)" />
|
||||
</el-form-item>
|
||||
<el-form-item label="签到周期" required>
|
||||
<el-input-number v-model="form.durationDays" :min="0.001" :step="1" style="width: 160px" />
|
||||
<span class="unit">天</span>
|
||||
<div class="presets">
|
||||
<el-tag
|
||||
v-for="preset in durationPresets"
|
||||
:key="preset.seconds"
|
||||
class="preset-tag"
|
||||
:effect="Math.round(form.durationDays * 86400) === preset.seconds ? 'dark' : 'plain'"
|
||||
:type="Math.round(form.durationDays * 86400) === preset.seconds ? 'primary' : 'info'"
|
||||
@click="applyPreset(preset.seconds)"
|
||||
>
|
||||
{{ preset.label }}
|
||||
</el-tag>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="提前提醒" required>
|
||||
<el-input-number v-model="form.remindHours" :min="0" :step="1" style="width: 160px" />
|
||||
<span class="unit">小时</span>
|
||||
<div class="muted">到期前会通过提醒渠道(设置页)提醒你签到</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="启用">
|
||||
<el-switch v-model="form.enabled" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="saving" @click="save">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.timer-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.timer-card:hover {
|
||||
transform: translateY(-3px);
|
||||
box-shadow: var(--gb-shadow-hover);
|
||||
}
|
||||
|
||||
.timer-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.timer-name {
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.timer-desc {
|
||||
margin: 6px 0 12px;
|
||||
min-height: 18px;
|
||||
}
|
||||
|
||||
.timer-countdown {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.01em;
|
||||
color: var(--gb-primary-deep);
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.timer-countdown.danger {
|
||||
color: var(--el-color-danger);
|
||||
}
|
||||
|
||||
.timer-bar {
|
||||
height: 6px;
|
||||
border-radius: 999px;
|
||||
background: var(--el-fill-color);
|
||||
overflow: hidden;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.timer-bar-fill {
|
||||
height: 100%;
|
||||
border-radius: 999px;
|
||||
transition:
|
||||
width 1s linear,
|
||||
background 0.5s var(--gb-ease);
|
||||
}
|
||||
|
||||
.timer-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px 14px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.timer-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.timer-switch {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.unit {
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.presets {
|
||||
margin-top: 8px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.preset-tag {
|
||||
margin-right: 6px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s var(--gb-ease);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"extends": "@vue/tsconfig/tsconfig.dom.json",
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"types": ["vite/client"],
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
},
|
||||
"allowArbitraryExtensions": true,
|
||||
|
||||
/* Linting */
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
"target": "es2023",
|
||||
"lib": ["ES2023"],
|
||||
"types": ["node"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"module": "nodenext",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
|
||||
/* Linting */
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { fileURLToPath, URL } from 'node:url'
|
||||
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
// 本地开发时把 API 请求转发到 Go 后端
|
||||
'/api': {
|
||||
target: 'http://localhost:8088',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
build: {
|
||||
// 构建产物直接输出到 Go embed 的目录
|
||||
outDir: '../dist',
|
||||
emptyOutDir: true,
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user