Files
ros-dpi/frontend/src/views/People.vue
T
Su YijunandClaude Opus 5.5 2585d15d53 feat: 发布 0.6.2,通知渠道照搬 EasySub、使用场景改为全局设置、识别顺序调整(debug12)
- 通知:14 种渠道与 EasySub 字段 / 实现一致(Telegram 代理与 getMe/getUpdates、飞书应用机器人、
  QQ 机器人、企业微信自建应用 + API 代理 + 检查应用、Webhook 超时与重试),旧配置自动迁移
- 场景:家庭 / 企业,企业称“员工 / 部门”;在 系统设置 → 场景与网络 全局切换,本地缓存避免菜单闪错
- 识别:国内域名列表先于官方 IP 段;IP 段重叠按最长前缀匹配;自动更新开关覆盖 ASN 与补充库

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 01:21:45 +08:00

463 lines
26 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div>
<div class="page-h">
<div class="seg tabs" role="tablist">
<button v-for="k in TABS" :key="k" role="tab" :aria-selected="tab === k" :class="{ on: tab === k }" @click="setTab(k)">{{ L('tab.' + k) }}</button>
</div>
<span class="pill grey" :title="t('scn.' + scene.id + '.desc')">{{ L('badge') }}</span>
<span class="sp" />
<RangeTabs v-if="tab === 'list'" />
</div>
<div v-if="err" class="err">{{ err }}</div>
<!-- 人员列表 -->
<template v-if="tab === 'list'">
<div class="panel">
<div class="panel-b head">
<div class="info-bar"><span class="ic">i</span><div><b>{{ L('title') }}</b><span>{{ L('info') }}</span></div></div>
<div class="sec">
<div class="chips">
<button class="chip-b" :class="{ on: !dept }" @click="dept = 0">{{ t('people.all') }}<span class="num">{{ d ? d.people.length : '' }}</span></button>
<button v-for="g in depts" :key="g.id" class="chip-b" :class="{ on: dept === g.id }" @click="dept = g.id">{{ g.name }}<span class="num">{{ g.n }}</span></button>
<button v-if="noDept" class="chip-b warn" :class="{ on: dept === -1 }" @click="dept = -1">{{ L('noDept') }}<span class="num">{{ noDept }}</span></button>
</div>
<span class="sp" />
<input class="input search" v-model="q" :placeholder="t('common.search')" :aria-label="t('common.search')" />
<template v-if="isAdmin">
<button class="btn sm" @click="deptOpen = true">{{ L('manageDept') }}</button>
<button class="btn sm primary" @click="edit(null)">{{ L('add') }}</button>
</template>
</div>
</div>
<div class="tbl-wrap" v-if="d">
<table class="tbl">
<thead><tr><th>{{ L('person') }}</th><th class="m-hide">{{ t('people.devices') }}</th><th class="r">{{ t('flow.bytes') }}</th>
<th class="r m-hide">{{ t('people.online') }}</th><th class="m-hide">{{ L('leisureShare') }}</th><th>{{ t('people.tags') }}</th><th v-if="isAdmin" /></tr></thead>
<tbody>
<tr v-for="p in shown" :key="p.id" class="link" @click="$router.push(`/people/${p.id}`)">
<td><span class="nm"><span class="dot" :class="{ on: p.online }" /><b>{{ p.name }}</b></span>
<span v-if="p.dept" class="pill grey dp">{{ p.dept }}</span><span v-else class="pill warn dp">{{ L('noDept') }}</span>
<div class="faint small m-only">{{ t('people.nDevices', { n: p.devices.length }) }} · {{ hours(p.online_seconds) }}</div></td>
<td class="m-hide"><span v-for="h in p.devices" :key="h.key" class="dv" :class="{ on: h.online }" :title="`${h.name} ${h.ip || ''}`">
<Icon :name="'dev-' + (h.dtype || 'unknown')" :size="16" /></span>
<span v-if="!p.devices.length" class="faint small">{{ t('people.noDevice') }}</span></td>
<td class="r num"><b>{{ bytes(p.total) }}</b><div class="small"><span class="up">↑{{ bytes(p.up) }}</span> <span class="down">↓{{ bytes(p.down) }}</span></div></td>
<td class="r num m-hide">{{ hours(p.online_seconds) }}</td>
<td class="m-hide eff">
<template v-if="p.work_online"><span class="bar"><i :style="{ width: pctN(p.leisure_work / p.work_online) + '%' }" :class="{ hi: p.leisure_work / p.work_online > .3 }" /></span>
<span class="num small">{{ pct(p.leisure_work / p.work_online, 0) }}</span></template>
<span v-else class="faint small">-</span></td>
<td><Tags :tags="p.tags" :max="3" /></td>
<td v-if="isAdmin" class="r"><button class="btn sm ghost" :title="t('common.edit')" :aria-label="t('common.edit')" @click.stop="edit(p)"><Icon name="edit" :size="15" /></button></td>
</tr>
</tbody>
</table>
<div v-if="!shown.length" class="empty">{{ d.people.length ? t('common.empty') : L('empty') }}</div>
</div>
<div v-else class="empty">{{ t('common.loading') }}</div>
</div>
<!-- 还没有归属的设备 -->
<div class="panel mt" v-if="d && isAdmin && d.unassigned.length">
<div class="panel-h"><h2>{{ t('people.unassigned') }}</h2><span class="faint small">{{ t('people.unassignedHint', { n: d.unassigned.length }) }}</span></div>
<div class="tbl-wrap">
<table class="tbl">
<thead><tr><th>{{ t('common.name') }}</th><th class="m-hide">IP / MAC</th><th class="m-hide">{{ t('people.dtype') }}</th><th class="r">{{ t('range.today') }}</th><th>{{ L('assignTo') }}</th></tr></thead>
<tbody>
<tr v-for="h in d.unassigned.slice(0, unLimit)" :key="h.key">
<td><span class="nm"><span class="dot" :class="{ on: h.online }" /><Icon :name="'dev-' + (h.dtype || 'unknown')" :size="16" class="dvi" />
<router-link :to="`/hosts/${enc(h.key)}`" class="hl">{{ h.name }}</router-link></span></td>
<td class="m-hide addr faint small">{{ h.ip }}<br />{{ h.mac || '' }}</td>
<td class="m-hide small">{{ t('dev.' + (h.dtype || 'unknown')) }}<span class="faint" v-if="h.vendor"> · {{ h.vendor }}</span></td>
<td class="r num">{{ bytes(h.today.total) }}</td>
<td><select class="input sel" :aria-label="L('assignTo')" @change="bind(h.key, $event.target.value)">
<option value="">—</option>
<option v-for="p in d.people" :key="p.id" :value="p.id">{{ p.name }}</option>
</select></td>
</tr>
</tbody>
</table>
<div class="more-row" v-if="d.unassigned.length > unLimit"><button class="btn sm" @click="unLimit += 50">{{ t('flow.showMore') }}</button></div>
</div>
</div>
</template>
<!-- 行为洞察(最近 7 天) -->
<template v-if="tab === 'insight'">
<div class="panel" v-if="ins">
<div class="panel-b head">
<div class="info-bar"><span class="ic">i</span><div><b>{{ L('tab.insight') }}</b><span>{{ L('insightInfo') }}</span></div></div>
</div>
<div class="panel-b">
<div class="tagcards">
<div v-for="g in tagGroups" :key="g.id" class="tc" :class="{ none: !g.items.length }">
<div class="tc-h"><b>{{ t('tag.' + g.id) }}</b><span class="faint small">{{ t('tagDesc.' + g.id, descArgs) }}</span></div>
<div v-if="!g.items.length" class="faint small">{{ t('people.nobody') }}</div>
<router-link v-for="x in g.items" :key="x.s.id" :to="`/people/${x.s.id}`" class="tc-r">
<span class="lv" :class="x.tag.level" /><span>{{ x.s.name }}</span><span class="sp" />
<span class="num small">{{ tagValue(x.tag) }}</span></router-link>
</div>
</div>
</div>
</div>
<div class="panel mt" v-if="ins">
<div class="panel-h"><h2>{{ L('rankTitle') }}</h2><span class="faint small">{{ t('people.last7') }}</span></div>
<div class="tbl-wrap">
<table class="tbl">
<thead><tr><th>{{ L('person') }}</th><th class="r">{{ t('people.online') }}</th><th class="r m-hide">{{ L('workOnline') }}</th>
<th>{{ L('leisureShare') }}</th>
<th class="r m-hide" v-if="scene.id === 'home'">{{ t('people.game') }}</th><th class="r m-hide" v-if="scene.id === 'home'">{{ t('people.shortVideo') }}</th>
<th class="r m-hide">{{ t('people.night') }}</th></tr></thead>
<tbody>
<tr v-for="s in rank" :key="s.id" class="link" @click="$router.push(`/people/${s.id}`)">
<td><b>{{ s.name }}</b><span v-if="s.dept" class="pill grey dp">{{ s.dept }}</span></td>
<td class="r num">{{ hours(s.online) }}</td>
<td class="r num m-hide">{{ hours(s.work_online) }}</td>
<td class="eff"><template v-if="s.work_online"><span class="bar"><i :style="{ width: pctN(s.leisure_work / s.work_online) + '%' }" :class="{ hi: s.leisure_work / s.work_online > .3 }" /></span>
<span class="num small">{{ pct(s.leisure_work / s.work_online, 0) }}</span></template><span v-else class="faint small">-</span></td>
<td class="r num m-hide" v-if="scene.id === 'home'">{{ hours(s.game) }}</td>
<td class="r num m-hide" v-if="scene.id === 'home'">{{ hours(s.short_video) }}</td>
<td class="r num m-hide">{{ hours(s.night) }}</td>
</tr>
</tbody>
</table>
<div v-if="!rank.length" class="empty">{{ L('empty') }}</div>
</div>
</div>
<div v-if="!ins" class="empty">{{ t('common.loading') }}</div>
</template>
<!-- 分析设置 -->
<template v-if="tab === 'settings' && cfg">
<div class="info-bar scn-now"><span class="ic">i</span><div><b>{{ t('people.sceneNow', { x: t('scn.' + scene.id + '.name') }) }}</b>
<span>{{ t('scn.' + scene.id + '.desc') }} <router-link class="link" to="/settings?tab=system">{{ t('people.sceneGo') }}</router-link></span></div></div>
<div class="panel mt">
<div class="panel-h"><h2>{{ L('workTime') }}</h2><span class="faint small">{{ L('workHint') }}</span></div>
<div class="panel-b form">
<div class="row days">
<label v-for="x in 7" :key="x" class="chk"><input type="checkbox" :checked="cfg.work_days.includes(String(x))" @change="toggleDay(x)" /> {{ t('audit.dow.' + x) }}</label>
</div>
<div v-for="(w, i) in cfg.work" :key="i" class="row">
<input class="input w" type="time" v-model="w[0]" :aria-label="t('audit.rule.from')" /><span>–</span>
<input class="input w" type="time" v-model="w[1]" :aria-label="t('audit.rule.to')" />
<button class="btn sm ghost" @click="cfg.work.splice(i, 1)" :aria-label="t('common.delete')"><Icon name="close" :size="14" /></button>
</div>
<div><button class="btn sm" @click="cfg.work.push(['09:00', '12:00'])">{{ t('people.addRange') }}</button></div>
<label>{{ t('people.nightWin') }}</label>
<div class="row"><input class="input w" type="time" v-model="cfg.night[0]" :aria-label="t('audit.rule.from')" /><span>–</span>
<input class="input w" type="time" v-model="cfg.night[1]" :aria-label="t('audit.rule.to')" /></div>
<label>{{ t('people.leisure') }}</label>
<div class="chips">
<button v-for="c in leisureChoices" :key="c.id" class="chip-b" :class="{ on: cfg.leisure.includes(c.id) }" @click="toggleLeisure(c.id)">{{ catName(c) }}</button>
</div>
<template v-if="cfg.scenario === 'home'">
<label>{{ t('people.limits') }}</label>
<div class="lims">
<label v-for="k in ['screen', 'game', 'short_video']" :key="k" class="lim">{{ t('people.lim.' + k) }}
<input class="input num" type="number" min="0" max="24" step="0.5" :value="cfg.limits[k] / 3600" @input="cfg.limits[k] = Math.round(+$event.target.value * 3600)" />
<span class="faint">{{ t('people.hoursUnit') }}</span></label>
</div>
<div class="faint small">{{ t('people.limitsHint') }}</div>
</template>
<div class="err" v-if="cfgErr">{{ cfgErr }}</div>
<div><button class="btn primary" @click="saveCfg">{{ cfgSaved ? t('common.saved') : t('common.save') }}</button></div>
</div>
</div>
</template>
<!-- 编辑人员 -->
<Modal :open="!!form" :title="form && form.id ? L('edit') : L('add')" width="560px" @close="form = null">
<form v-if="form" class="pf" @submit.prevent="save">
<label for="pf-name">{{ t('common.name') }}</label>
<input id="pf-name" class="input" v-model.trim="form.name" required maxlength="64" />
<label for="pf-dept">{{ L('dept') }} <span class="req">*</span></label>
<div class="row">
<select id="pf-dept" class="input grow" v-model="form.dept_id" required>
<option :value="null" disabled>{{ L('pickDept') }}</option>
<option v-for="g in d.departments" :key="g.id" :value="g.id">{{ g.name }}</option></select>
<input class="input grow" v-model.trim="quickDept" :placeholder="L('deptName')" :aria-label="L('deptName')" @keydown.enter.prevent="quickAddDept" />
<button type="button" class="btn sm" :disabled="!quickDept" @click="quickAddDept">{{ L('newDept') }}</button>
</div>
<label for="pf-q">{{ t('people.devices') }}</label>
<input id="pf-q" class="input" v-model="hq" :placeholder="t('people.deviceSearch')" />
<div class="hostbox">
<label v-for="h in hostChoices" :key="h.key" class="chk"><input type="checkbox" :checked="form.hosts.includes(h.key)" @change="toggleHost(h.key)" />
<Icon :name="'dev-' + (h.dtype || 'unknown')" :size="15" /> {{ h.name }} <span class="faint addr small">{{ h.ip }}</span>
<span v-if="h.owner && h.owner !== form.id" class="pill grey dp">{{ h.ownerName }}</span></label>
</div>
<label for="pf-note">{{ t('people.note') }}</label>
<input id="pf-note" class="input" v-model="form.note" maxlength="255" />
<div class="err" v-if="formErr">{{ formErr }}</div>
<div class="row end">
<button v-if="form.id" type="button" class="btn danger" @click="del">{{ t('common.delete') }}</button>
<span class="sp" />
<button type="button" class="btn" @click="form = null">{{ t('common.cancel') }}</button>
<button class="btn primary">{{ t('common.save') }}</button></div>
</form>
</Modal>
<!-- 部门 / 分组 -->
<Modal :open="deptOpen" :title="L('manageDept')" width="460px" @close="deptOpen = false">
<div class="pf">
<div v-for="g in d ? d.departments : []" :key="g.id" class="row">
<input class="input grow" v-model="g.name" :aria-label="t('common.name')" />
<button class="btn sm" @click="renameDept(g)">{{ t('common.save') }}</button>
<button class="btn sm danger" @click="delDept(g)">{{ t('common.delete') }}</button>
</div>
<form class="row" @submit.prevent="addDept">
<input class="input grow" v-model.trim="newDept" :placeholder="L('deptName')" :aria-label="L('deptName')" />
<button class="btn sm primary" :disabled="!newDept">{{ t('common.add') }}</button>
</form>
<div class="err" v-if="deptErr">{{ deptErr }}</div>
</div>
</Modal>
</div>
</template>
<script setup>
import { computed, onMounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRoute, useRouter } from 'vue-router'
import http, { enc, isAdmin } from '../api.js'
import Icon from '../components/Icon.vue'
import Modal from '../components/Modal.vue'
import RangeTabs from '../components/RangeTabs.vue'
import Tags from '../components/Tags.vue'
import { bytes, catName, pct } from '../format.js'
import { hours, L, loadScene, scene, syncScene } from '../scenario.js'
import { ui } from '../state.js'
const { t } = useI18n()
const route = useRoute()
const router = useRouter()
const TABS = computed(() => (isAdmin.value ? ['list', 'insight', 'settings'] : ['list', 'insight']))
const tab = computed(() => (TABS.value.includes(route.query.tab) ? route.query.tab : 'list'))
const setTab = k => router.replace({ query: k === 'list' ? {} : { tab: k } })
const err = ref('')
const pctN = x => Math.min(100, Math.round((x || 0) * 100))
// ---------- 列表 ----------
const d = ref(null)
const dept = ref(0)
const q = ref('')
const unLimit = ref(50)
async function load() {
try { d.value = await http.get('/people', { params: { range: ui.range } }); syncScene(d.value.scenario); err.value = '' } catch (e) { err.value = e.message }
const want = +route.query.edit
if (want && d.value && isAdmin.value && !form.value) {
const p = d.value.people.find(x => x.id === want)
router.replace({ query: {} })
if (p) edit(p)
}
}
const depts = computed(() => (d.value ? d.value.departments.map(g => ({ ...g, n: d.value.people.filter(p => p.dept_id === g.id).length })) : []))
const noDept = computed(() => (d.value ? d.value.people.filter(p => !p.dept_id || !d.value.departments.some(g => g.id === p.dept_id)).length : 0))
const shown = computed(() => {
const s = q.value.trim().toLowerCase()
const known = id => d.value.departments.some(g => g.id === id)
return (d.value ? d.value.people : []).filter(p => (!dept.value || (dept.value === -1 ? !known(p.dept_id) : p.dept_id === dept.value))
&& (!s || p.name.toLowerCase().includes(s) || p.devices.some(h => (h.name + ' ' + (h.ip || '')).toLowerCase().includes(s))))
})
async function bind(host, pid) {
if (!pid) return
await http.put('/devices/bind', { host, person_id: +pid })
await load()
}
// ---------- 编辑人员 ----------
const form = ref(null)
const formErr = ref('')
const hq = ref('')
const allHosts = ref([])
async function edit(p) {
formErr.value = ''
hq.value = ''
if (!allHosts.value.length) allHosts.value = await http.get('/hosts')
form.value = p ? { id: p.id, name: p.name, dept_id: p.dept_id, note: p.note || '', hosts: p.devices.map(h => h.key) }
: { id: null, name: '', dept_id: dept.value > 0 ? dept.value : (d.value.departments.length === 1 ? d.value.departments[0].id : null), note: '', hosts: [] }
quickDept.value = ''
}
const hostChoices = computed(() => {
const s = hq.value.trim().toLowerCase()
const owner = {}
for (const p of d.value ? d.value.people : []) for (const h of p.devices) owner[h.key] = p
return allHosts.value.filter(h => !s || [h.name, h.ip, h.mac].some(v => v && v.toLowerCase().includes(s)))
.map(h => ({ ...h, owner: owner[h.key] && owner[h.key].id, ownerName: owner[h.key] && owner[h.key].name }))
.sort((a, b) => (form.value.hosts.includes(b.key) - form.value.hosts.includes(a.key)) || (!!a.owner - !!b.owner))
.slice(0, 150)
})
function toggleHost(k) {
const i = form.value.hosts.indexOf(k)
i >= 0 ? form.value.hosts.splice(i, 1) : form.value.hosts.push(k)
}
const quickDept = ref('')
async function quickAddDept() {
formErr.value = ''
try {
const g = await http.post('/departments', { name: quickDept.value })
await load()
form.value.dept_id = g.id
quickDept.value = ''
} catch (e) { formErr.value = e.message }
}
async function save() {
formErr.value = ''
const f = form.value
if (!f.dept_id) { formErr.value = L('pickDept'); return }
try {
const body = { name: f.name, dept_id: f.dept_id, note: f.note || null, hosts: f.hosts }
if (f.id) await http.put(`/people/${f.id}`, body)
else await http.post('/people', body)
form.value = null
await Promise.all([load(), loadScene(true)])
} catch (e) { formErr.value = e.message }
}
async function del() {
if (!confirm(t('common.confirmDelete'))) return
try {
await http.delete(`/people/${form.value.id}`)
form.value = null
await Promise.all([load(), loadScene(true)])
} catch (e) { formErr.value = e.message }
}
// ---------- 部门 ----------
const deptOpen = ref(false)
const newDept = ref('')
const deptErr = ref('')
async function addDept() {
deptErr.value = ''
try { await http.post('/departments', { name: newDept.value }); newDept.value = ''; await load() } catch (e) { deptErr.value = e.message }
}
async function renameDept(g) {
deptErr.value = ''
try { await http.put(`/departments/${g.id}`, { name: g.name }); await load() } catch (e) { deptErr.value = e.message }
}
async function delDept(g) {
deptErr.value = ''
if (!confirm(t('common.confirmDelete'))) return
try {
await http.delete(`/departments/${g.id}`)
if (dept.value === g.id) dept.value = 0
await load()
} catch (e) { deptErr.value = e.message }
}
// ---------- 洞察 ----------
const ins = ref(null)
async function loadIns() {
try { ins.value = await http.get('/insights'); syncScene(ins.value.scenario) } catch (e) { err.value = e.message }
}
const TAG_IDS = { enterprise: ['job', 'stock_work', 'game_work', 'video_work', 'shop_work', 'late_night'],
home: ['adult', 'screen_over', 'game_over', 'short_video_over', 'late_night', 'study_leisure'] }
const tagGroups = computed(() => (TAG_IDS[scene.id] || []).map(id => ({
id, items: (ins.value ? ins.value.subjects : []).flatMap(s => s.tags.filter(x => x.id === id).map(tag => ({ s, tag })))
.sort((a, b) => (b.tag.level === 'high') - (a.tag.level === 'high') || b.tag.value - a.tag.value),
})))
const descArgs = computed(() => {
const c = ins.value ? ins.value.config : {}
const l = c.limits || {}
return { screen: hours(l.screen), game: hours(l.game), sv: hours(l.short_video), night: (c.night || []).join('–') }
})
const tagValue = x => (x.unit === 'days' ? t('people.days', { n: x.value }) : hours(x.value))
const rank = computed(() => (ins.value ? [...ins.value.subjects].sort((a, b) =>
(b.work_online ? b.leisure_work / b.work_online : -1) - (a.work_online ? a.leisure_work / a.work_online : -1)) : []))
// ---------- 设置 ----------
const cfg = ref(null)
const choices = ref([])
const cfgErr = ref('')
const cfgSaved = ref(false)
async function loadCfg() {
err.value = ''
try {
const r = await http.get('/analysis/settings')
cfg.value = JSON.parse(JSON.stringify(r.config))
choices.value = r.choices
} catch (e) { err.value = e.message }
}
const leisureChoices = computed(() => choices.value.filter(c => c.kind === 'group' && !['base', 'unknown'].includes(c.id))
.concat(choices.value.filter(c => ['short_video', 'shopping', 'live', 'mobile_game', 'social'].includes(c.id) && c.kind === 'category')))
function toggleDay(x) {
const s = new Set(cfg.value.work_days.split(''))
s.has(String(x)) ? s.delete(String(x)) : s.add(String(x))
cfg.value.work_days = [...s].sort().join('')
}
function toggleLeisure(id) {
const i = cfg.value.leisure.indexOf(id)
i >= 0 ? cfg.value.leisure.splice(i, 1) : cfg.value.leisure.push(id)
}
async function saveCfg() {
cfgErr.value = ''
try {
cfg.value = (await http.put('/analysis/settings', cfg.value)).config
syncScene(cfg.value.scenario)
cfgSaved.value = true
setTimeout(() => (cfgSaved.value = false), 1500)
} catch (e) { cfgErr.value = e.message }
}
onMounted(() => loadScene())
watch([tab, () => ui.range], () => {
if (tab.value === 'list') load()
else if (tab.value === 'insight') loadIns()
else if (tab.value === 'settings') loadCfg()
}, { immediate: true })
</script>
<style scoped>
.tabs button { font-size: 15px; padding: 8px 14px 11px; }
.head { display: grid; gap: 14px; padding-bottom: 6px; }
.chips { display: flex; gap: 6px; flex-wrap: wrap; }
.chip-b { border: 1px solid var(--line); background: var(--panel); color: var(--ink); border-radius: 16px; padding: 4px 12px; font: inherit; font-size: 13px; cursor: pointer; display: inline-flex; gap: 6px; align-items: center; }
.chip-b .num { color: var(--faint); font-size: 12px; }
.chip-b.on { border-color: var(--brand-ink); background: var(--brand-weak); color: var(--brand-ink); }
.chip-b.warn { border-color: var(--warn); }
.req { color: var(--bad); }
.search { width: 200px; height: 32px; }
.nm { display: inline-flex; align-items: center; gap: 8px; }
.dp { margin-left: 8px; height: 20px; font-size: 11px; }
.dv { display: inline-flex; color: var(--faint); margin-right: 4px; }
.dv.on { color: var(--ok); }
.dvi { color: var(--muted); }
.hl:hover { color: var(--brand-ink); }
.eff { white-space: nowrap; }
.bar { display: inline-block; width: 80px; height: 6px; border-radius: 3px; background: var(--sunk); vertical-align: middle; margin-right: 8px; overflow: hidden; }
.bar i { display: block; height: 100%; background: var(--accent); border-radius: 3px; }
.bar i.hi { background: var(--warn); }
.sel { height: 30px; min-width: 120px; }
.more-row { display: flex; justify-content: center; padding: 12px; }
.tagcards { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 12px; }
.tc { background: var(--sunk); border-radius: 12px; padding: 12px 14px; display: grid; gap: 6px; align-content: start; }
.tc.none { opacity: .7; }
.tc-h { display: grid; gap: 2px; margin-bottom: 4px; }
.tc-r { display: flex; align-items: center; gap: 8px; font-size: 14px; padding: 4px 0; border-top: 1px solid var(--line); }
.tc-r:hover b, .tc-r:hover span:nth-child(2) { color: var(--brand-ink); }
.tc-r .sp { flex: 1; }
.lv { width: 8px; height: 8px; border-radius: 50%; background: var(--warn); }
.lv.high { background: var(--bad); }
.scn { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 12px; }
.scn-c { display: flex; gap: 12px; align-items: flex-start; border: 1px solid var(--line); border-radius: 12px; padding: 14px; cursor: pointer; }
.scn-c input { display: none; }
.scn-c :deep(svg) { color: var(--brand-ink); flex: none; margin-top: 2px; }
.scn-c div { display: grid; gap: 4px; }
.scn-c.on { border-color: var(--brand-ink); background: var(--brand-weak); }
.form { display: grid; gap: 10px; max-width: 640px; }
.form > label { font-size: 13px; color: var(--muted); margin-top: 6px; }
.row { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; }
.row.end { justify-content: flex-end; margin-top: 8px; }
.row .sp { flex: 1; }
.days { gap: 14px; }
.w { width: 130px; }
.grow { flex: 1; min-width: 160px; }
.chk { display: inline-flex; gap: 6px; align-items: center; font-size: 13px; color: var(--muted); cursor: pointer; }
.lims { display: flex; gap: 16px; flex-wrap: wrap; }
.lim { display: inline-flex; gap: 8px; align-items: center; font-size: 13px; }
.lim input { width: 80px; }
.pf { display: grid; gap: 8px; }
.pf > label { font-size: 13px; color: var(--muted); margin-top: 4px; }
.hostbox { max-height: 220px; overflow-y: auto; border: 1px solid var(--line); border-radius: 8px; padding: 8px 10px; display: grid; gap: 4px; }
.hostbox .chk { color: var(--ink); }
@media (max-width: 640px) { .search { width: 100%; } }
</style>