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,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>
|
||||
Reference in New Issue
Block a user