新增: 挂机模式 P1 总装: 设置面板+顶栏徽章+边界卡精简+监控告警推送

- 新增 UnattendedPanel.vue 挂机配置面板(引擎自动认领看板任务,Settings KV 热读即改即生效)
- Settings.vue 接入挂机面板;searchIndex.ts 补挂机搜索词条
- TopBar.vue 新增挂机进行中徽章(脉冲动效+透明度过渡)
- TaskBoundaryCard.vue 精简边界状态判定(wallState 合并比对,删冗余 watch)
- i18n zh-CN/en: settings/ai/aiChat 六文件补挂机词条双语
- unattended.rs 新增 push_monitor_alert 监控告警推送(异常快照+告警头直发 monitor_chat)
- feishu_notify.rs push/push_card 出口重构收敛
- lib.rs 注册 unattended_status/unattended_set_enabled 两 IPC
- aiShared.ts/useAiStreamingEvents.ts 挂机状态流接入
This commit is contained in:
lxy
2026-10-07 22:03:41 +08:00
parent e60bd38444
commit 2441101978
16 changed files with 525 additions and 264 deletions
+183
View File
@@ -0,0 +1,183 @@
<template>
<!-- ═══ 挂机模式(引擎自动认领看板任务持续执行;配置走 Settings KV 热读,改后即时生效) ═══ -->
<section class="panel">
<div class="panel-header">
<h2>{{ $t('settings.panelUnattended') }}</h2>
</div>
<div class="unattended-settings">
<SettingRow :label="$t('settings.labelUnattendedEnabled')" :desc="$t('settings.descUnattendedEnabled')">
<label class="toggle">
<input
type="checkbox"
:checked="enabled"
:disabled="toggling"
@change="onEnabledChange(($event.target as HTMLInputElement).checked)"
/>
<span class="toggle-slider"></span>
</label>
</SettingRow>
<SettingRow :label="$t('settings.labelUnattendedScope')" :desc="$t('settings.descUnattendedScope')">
<div class="unattended-radios">
<label class="unattended-radio" :class="{ 'is-checked': scope === 'red_only' }">
<input v-model="scope" type="radio" value="red_only" />
<span>{{ $t('settings.unattendedScopeRed') }}</span>
</label>
<label class="unattended-radio" :class="{ 'is-checked': scope === 'all_open' }">
<input v-model="scope" type="radio" value="all_open" />
<span>{{ $t('settings.unattendedScopeAll') }}</span>
</label>
</div>
</SettingRow>
<SettingRow :label="$t('settings.labelUnattendedMonitorChat')" :desc="$t('settings.descUnattendedMonitorChat')">
<input
v-model="monitorChat"
type="text"
class="setting-input unattended-input"
:placeholder="$t('settings.phUnattendedMonitorChat')"
autocomplete="off"
/>
</SettingRow>
<button type="button" class="unattended-advanced-toggle" @click="advancedOpen = !advancedOpen">
<span class="unattended-chevron" :class="{ 'is-open': advancedOpen }">▶</span>
{{ $t('settings.unattendedAdvanced') }}
</button>
<div v-if="advancedOpen" class="unattended-advanced">
<SettingRow :label="$t('settings.labelUnattendedPollSecs')" :desc="$t('settings.descUnattendedPollSecs')">
<input
:value="pollSecs"
type="text"
inputmode="numeric"
class="setting-input unattended-input unattended-input--num"
@change="onPollSecsChange(($event.target as HTMLInputElement).value)"
/>
</SettingRow>
</div>
</div>
</section>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { invoke } from '@tauri-apps/api/core'
import { useI18n } from 'vue-i18n'
import { useAppSettingsStore } from '@/stores/appSettings'
import SettingRow from './SettingRow.vue'
// ============================================================
// 挂机模式面板 — 引擎配置读写全走 Settings KV(后端每轮热读,改后即时生效):
// - 模式开关走专用 IPC unattended_set_enabled(写 KV + 立即唤醒轮询器 + 飞书确认卡),
// 其余三项(范围/推送目标/轮询周期)走 appSettings store(settings_get/settings_set,自带防抖)
// - 开关关闭语义 = 引擎空转(下一轮只读配置即停),不杀在途任务
// ============================================================
/** 后端引擎状态(unattended_status 纯读,作开关回显的权威真值) */
interface UnattendedStatus {
enabled: boolean
claim_scope: string
in_flight_conv: string | null
breaker_tripped: boolean
poll_secs: number
}
/** 轮询周期下限(与后端 MIN_POLL_SECS 同源钳制,防误配热循环) */
const MIN_POLL_SECS = 30
const DEFAULT_POLL_SECS = 300
const emit = defineEmits<{ (e: 'toast', msg: string, type: 'error' | 'warning' | 'info' | 'success'): void }>()
const { t } = useI18n()
const appSettings = useAppSettingsStore()
const enabled = ref(false)
const toggling = ref(false)
const advancedOpen = ref(false)
// 领单范围/推送目标:store 防抖落库(v-model 即存);范围历史脏值归一到 red_only
const scope = appSettings.useSetting<string>('df-unattended-claim-scope', 'red_only')
const monitorChat = appSettings.useSetting<string>('df-monitor-chat-id', '')
const pollSecs = appSettings.useSetting<number>('df-unattended-poll-secs', DEFAULT_POLL_SECS)
onMounted(async () => {
enabled.value = appSettings.get<boolean>('df-unattended-enabled', false)
if (scope.value !== 'all_open') scope.value = 'red_only'
try {
const s = await invoke<UnattendedStatus>('unattended_status')
enabled.value = s.enabled
// 展示值对齐后端生效值(后端有 30s 下限钳制,KV 手改成更小值时这里自愈回写)
if (s.poll_secs !== pollSecs.value) pollSecs.value = s.poll_secs
} catch (e) {
console.error('[UNATTENDED] 状态读取失败,回退 KV 缓存值:', e)
}
})
/** 模式开关:专用 IPC(确认卡在后端推);失败回拨开关并 toast */
async function onEnabledChange(next: boolean) {
if (toggling.value || next === enabled.value) return
toggling.value = true
try {
await invoke('unattended_set_enabled', { enabled: next })
enabled.value = next
// 同步 store 缓存(后端直写 KV 不经前端 store,不回写则下次进面板读到旧值)
void appSettings.set('df-unattended-enabled', next)
emit('toast', t(next ? 'settings.toastUnattendedOn' : 'settings.toastUnattendedOff'), 'success')
} catch (e) {
console.error('[UNATTENDED] 开关失败:', e)
emit('toast', t('settings.toastUnattendedFail', { msg: String(e) }), 'error')
} finally {
toggling.value = false
}
}
/** 轮询周期:非法输入回退当前值;合法值钳下限后落库(store 防抖) */
function onPollSecsChange(raw: string) {
const n = Number.parseInt(raw.trim(), 10)
if (!Number.isFinite(n) || n <= 0) {
pollSecs.value = appSettings.get<number>('df-unattended-poll-secs', DEFAULT_POLL_SECS)
return
}
pollSecs.value = Math.max(n, MIN_POLL_SECS)
}
</script>
<style scoped>
/* panel/panel-header/toggle/setting-input 均在 settings.css 全局,此处仅本面板特有样式 */
.unattended-settings { display: flex; flex-direction: column; gap: 4px; }
.unattended-input { width: 280px; }
.unattended-input--num { width: 110px; }
.unattended-radios { display: flex; gap: 16px; flex-wrap: wrap; }
.unattended-radio {
display: inline-flex;
align-items: center;
gap: 4px;
cursor: pointer;
color: var(--df-text-dim);
font-size: 12px;
}
.unattended-radio.is-checked { color: var(--df-accent); }
.unattended-radio input { accent-color: var(--df-accent); margin: 0; }
.unattended-advanced-toggle {
display: inline-flex;
align-items: center;
gap: 5px;
align-self: flex-start;
margin-top: 6px;
padding: 2px 4px;
border: none;
background: none;
color: var(--df-text-dim);
font-size: 12px;
cursor: pointer;
border-radius: var(--df-radius-sm);
}
.unattended-advanced-toggle:hover { color: var(--df-text); }
.unattended-chevron {
display: inline-block;
font-size: 9px;
transition: transform 0.15s var(--df-ease);
}
.unattended-chevron.is-open { transform: rotate(90deg); }
.unattended-advanced { display: flex; flex-direction: column; gap: 4px; }
</style>
+6
View File
@@ -86,6 +86,12 @@ export const SETTINGS_INDEX: SettingIndexEntry[] = [
{ category: 'security', key: 'scriptBlacklist', labelKey: 'settings.labelScriptBlacklist', descKey: 'settings.descScriptBlacklist' },
// ===== 集成 =====
// 挂机模式(引擎自动认领看板任务;TopBar 徽章点击跳转即落到本类)
{ category: 'integrations', key: 'unattended', labelKey: 'settings.panelUnattended' },
{ category: 'integrations', key: 'unattendedEnabled', labelKey: 'settings.labelUnattendedEnabled', descKey: 'settings.descUnattendedEnabled' },
{ category: 'integrations', key: 'unattendedScope', labelKey: 'settings.labelUnattendedScope', descKey: 'settings.descUnattendedScope' },
{ category: 'integrations', key: 'unattendedMonitorChat', labelKey: 'settings.labelUnattendedMonitorChat', descKey: 'settings.descUnattendedMonitorChat' },
{ category: 'integrations', key: 'unattendedPollSecs', labelKey: 'settings.labelUnattendedPollSecs', descKey: 'settings.descUnattendedPollSecs' },
{ category: 'integrations', key: 'feishu', labelKey: 'settings.panelFeishu' },
{ category: 'integrations', key: 'feishuEnabled', labelKey: 'settings.labelFeishuEnabled', descKey: 'settings.descFeishuEnabled' },
{ category: 'integrations', key: 'feishuAppId', labelKey: 'settings.labelFeishuAppId', descKey: 'settings.descFeishuAppId' },