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