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:
mei
2026-07-26 22:19:41 +08:00
co-authored by Claude Fable 5
parent 89e8b7fd66
commit 79774070fc
110 changed files with 8936 additions and 849 deletions
+6
View File
@@ -0,0 +1,6 @@
<script setup lang="ts">
</script>
<template>
<RouterView />
</template>
+56
View File
@@ -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)),
}
+82
View File
@@ -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 }
+157
View File
@@ -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

+206
View File
@@ -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

+179
View File
@@ -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>
+22
View File
@@ -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>
+313
View File
@@ -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>
+16
View File
@@ -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')
+83
View File
@@ -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
+46
View File
@@ -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,
}
})
+35
View File
@@ -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 }
})
+41
View File
@@ -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 },
]
+207
View File
@@ -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>
+389
View File
@@ -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>
+270
View File
@@ -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>
+136
View File
@@ -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>
+357
View File
@@ -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>
+175
View File
@@ -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>
+438
View File
@@ -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>