Files
dy/frontend/src/views/Rules.vue
T
2026-07-28 15:04:17 +08:00

1069 lines
28 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.
<script setup>
import { ref, computed, onMounted, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { message } from 'ant-design-vue'
import { useIsMobile } from '../composables/useIsMobile'
import {
PlusOutlined,
DeleteOutlined,
EditOutlined,
ArrowUpOutlined,
ArrowDownOutlined,
FilterOutlined
} from '@ant-design/icons-vue'
import api from '../api'
import ReplyRuleEditor from '../components/ReplyRuleEditor.vue'
import {
emptyReplyForm,
parseReplyMessages,
serializeReplyContent,
countReplyMessages,
formatReplyPreview,
validateReplies,
replyTypeOptions,
ensureCardPagesForReplies
} from '../utils/replyRules'
const route = useRoute()
const router = useRouter()
const isMobile = useIsMobile()
const modalWidth = computed(() => (isMobile.value ? 'calc(100vw - 32px)' : 720))
const rules = ref([])
const accounts = ref([])
const loading = ref(false)
const rulesPage = ref(1)
const rulesPageSize = ref(10)
const rulesTotal = ref(0)
const modalVisible = ref(false)
const modalTitle = ref('新增回复规则')
const editingRuleId = ref(null)
const filterAccountId = ref(null)
const savingRule = ref(false)
const ruleForm = ref({
account_id: null,
keyword: '',
match_type: 'contains',
replies: [emptyReplyForm()]
})
// 账号筛选与分页均由后端完成,rules 始终只保存当前页数据
const filteredRules = computed(() =>
[...rules.value].sort((a, b) => {
const orderDiff = (a.sort_order ?? 0) - (b.sort_order ?? 0)
if (orderDiff !== 0) return orderDiff
return a.id - b.id
})
)
const filterAccountLabel = computed(() => {
if (!filterAccountId.value) return ''
return getAccountName(filterAccountId.value)
})
const getAccountLabel = (acc) => {
if (!acc) return ''
if (acc.username) return acc.username
if (acc.phone) return acc.phone
return `账号 #${acc.id}`
}
const buildAccountSearchText = (acc) =>
[acc.username, acc.phone, acc.id != null ? String(acc.id) : null].filter(Boolean).join(' ')
const filterAccountOption = (input, option) => {
const q = (input || '').trim().toLowerCase()
if (!q) return true
const text = (option.searchText || option.label || '').toLowerCase()
return text.includes(q)
}
const toSelectAccountId = (accountId) => {
if (accountId === null || accountId === undefined || accountId === '') {
return null
}
const id = Number(accountId)
return Number.isFinite(id) ? id : null
}
const fromSelectAccountId = (value) => {
if (value === null || value === undefined || value === '') {
return null
}
const id = Number(value)
return Number.isFinite(id) ? id : null
}
const defaultAccountId = () => {
if (filterAccountId.value) return Number(filterAccountId.value)
return accounts.value.length ? Number(accounts.value[0].id) : null
}
const accountOptions = computed(() =>
accounts.value.map((acc) => ({
value: Number(acc.id),
label: getAccountLabel(acc),
searchText: buildAccountSearchText(acc)
}))
)
const cooldownAccountId = ref(null)
const cooldownValue = ref(null)
const cooldownSaving = ref(false)
const cooldownAccount = computed(() =>
accounts.value.find((acc) => Number(acc.id) === Number(cooldownAccountId.value)) || null
)
const cooldownEffective = computed(() => cooldownAccount.value?.reply_cooldown_effective ?? 0)
const syncCooldownFromAccount = () => {
const acc = cooldownAccount.value
cooldownValue.value = acc && acc.reply_cooldown_seconds != null ? acc.reply_cooldown_seconds : null
}
const onCooldownAccountChange = () => {
syncCooldownFromAccount()
}
const saveCooldown = async () => {
const id = Number(cooldownAccountId.value)
if (!id) {
message.warning('请选择要设置的账号')
return
}
cooldownSaving.value = true
try {
const payload = {
reply_cooldown_seconds:
cooldownValue.value === null || cooldownValue.value === ''
? -1
: Math.max(0, Number(cooldownValue.value) || 0)
}
await api.put(`/accounts/${id}`, payload)
message.success('回复冷却时间已保存(重新启动该账号托管后生效)')
await fetchAccounts()
syncCooldownFromAccount()
} catch (error) {
message.error(error.response?.data?.detail || '保存冷却时间失败')
} finally {
cooldownSaving.value = false
}
}
const replyTypeLabelOptions = replyTypeOptions.map((opt) => ({
...opt,
label: opt.label
}))
const validateReplyForm = () => {
const err = validateReplies(ruleForm.value.replies)
if (err) {
message.warning(err)
return false
}
return true
}
const fetchRules = async () => {
try {
loading.value = true
const res = await api.get('/rules', {
params: {
page: rulesPage.value,
page_size: rulesPageSize.value,
account_id: filterAccountId.value || undefined
}
})
rules.value = res.data.items || []
rulesTotal.value = res.data.total || 0
// 删除后当前页可能超界,自动回退到最后一页
const maxPage = Math.max(1, Math.ceil(rulesTotal.value / rulesPageSize.value) || 1)
if (rulesPage.value > maxPage) {
rulesPage.value = maxPage
await fetchRules()
}
} catch (error) {
message.error('获取规则列表失败')
} finally {
loading.value = false
}
}
const rulesPagination = computed(() => ({
current: rulesPage.value,
pageSize: rulesPageSize.value,
total: rulesTotal.value,
showSizeChanger: true,
pageSizeOptions: ['10', '20', '50'],
showTotal: (total) => `共 ${total} 条规则`
}))
const handleTableChange = (pagination) => {
rulesPage.value = pagination.current
rulesPageSize.value = pagination.pageSize
fetchRules()
}
const handleMobilePageChange = (page, pageSize) => {
rulesPage.value = page
rulesPageSize.value = pageSize
fetchRules()
}
watch(filterAccountId, () => {
rulesPage.value = 1
fetchRules()
})
const fetchAccounts = async () => {
try {
const res = await api.get('/account-options')
accounts.value = Array.isArray(res.data) ? res.data : res.data?.items || []
if (!cooldownAccountId.value && accounts.value.length) {
cooldownAccountId.value = filterAccountId.value
? Number(filterAccountId.value)
: Number(accounts.value[0].id)
}
syncCooldownFromAccount()
} catch (error) {
console.error(error)
message.error('获取账号列表失败')
}
}
const onAccountFilterChange = (value) => {
if (value) {
cooldownAccountId.value = Number(value)
syncCooldownFromAccount()
}
const nextId = value ? String(value) : ''
if (String(route.query.account_id || '') !== nextId) {
router.replace({
path: route.path,
query: value ? { account_id: nextId } : {}
})
}
}
const ensureAccountsLoaded = async () => {
if (!accounts.value.length) {
await fetchAccounts()
}
}
const openAddModal = async () => {
await ensureAccountsLoaded()
if (!accounts.value.length) {
message.warning('请先添加托管账号,再创建自动回复规则')
return
}
editingRuleId.value = null
modalTitle.value = '新增回复规则'
ruleForm.value = {
account_id: defaultAccountId(),
keyword: '',
match_type: 'contains',
replies: [emptyReplyForm()]
}
modalVisible.value = true
}
const clearAccountFilter = () => {
filterAccountId.value = null
onAccountFilterChange(null)
}
const openEditModal = async (rule) => {
await ensureAccountsLoaded()
editingRuleId.value = rule.id
modalTitle.value = '编辑回复规则'
ruleForm.value = {
account_id: toSelectAccountId(rule.account_id),
keyword: rule.keyword,
match_type: rule.match_type,
replies: parseReplyMessages(rule.reply_content)
}
modalVisible.value = true
}
const handleSaveRule = async () => {
const accountId = fromSelectAccountId(ruleForm.value.account_id)
if (!accountId) {
message.warning('请选择适用账号(每条规则仅对指定账号生效)')
return
}
if (ruleForm.value.match_type !== 'default' && !ruleForm.value.keyword.trim()) {
message.warning('请输入匹配关键词')
return
}
if (!validateReplyForm()) {
return Promise.reject(new Error('validation'))
}
savingRule.value = true
try {
const repliesWithCards = await ensureCardPagesForReplies(ruleForm.value.replies, api)
ruleForm.value.replies = repliesWithCards
const payload = {
account_id: accountId,
keyword: ruleForm.value.keyword,
match_type: ruleForm.value.match_type,
reply_content: serializeReplyContent(ruleForm.value.replies)
}
if (editingRuleId.value) {
await api.put(`/rules/${editingRuleId.value}`, payload)
message.success('规则更新成功')
} else {
await api.post('/rules', payload)
message.success('规则创建成功')
}
modalVisible.value = false
fetchRules()
} catch (error) {
if (error?.message === 'validation') return
message.error(error.response?.data?.detail || '保存规则失败')
} finally {
savingRule.value = false
}
}
const handleDeleteRule = async (id) => {
try {
await api.delete(`/rules/${id}`)
message.success('删除规则成功')
fetchRules()
} catch (error) {
message.error('删除规则失败')
}
}
const handleToggleRule = async (rule) => {
try {
await api.post(`/rules/${rule.id}/toggle`)
message.success(`${rule.is_active ? '已禁用' : '已启用'}该规则`)
fetchRules()
} catch (error) {
message.error('操作失败')
}
}
const getReplyTypeLabel = (raw) => {
const form = parseReplyMessages(raw)[0]
const item = replyTypeLabelOptions.find((o) => o.value === form.reply_type)
return item?.label || '文本'
}
const getReplyTypeColor = (raw) => {
const form = parseReplyMessages(raw)[0]
if (form.reply_type === 'link') return 'geekblue'
if (form.reply_type === 'card') return 'cyan'
return 'green'
}
const handleMoveRule = async (rule, direction) => {
try {
// 服务端在同账号规则内交换排序,跨页也能正确移动
await api.post(`/rules/${rule.id}/move`, { direction })
await fetchRules()
} catch (error) {
message.error('调整排序失败')
}
}
const getAccountName = (accountId) => {
if (accountId === null || accountId === undefined || accountId === '') {
return '未绑定(已失效)'
}
const id = Number(accountId)
const acc = accounts.value.find(a => Number(a.id) === id)
return acc ? getAccountLabel(acc) : `账号 #${id}`
}
const matchTypeLabel = (type) => ({
exact: '精确匹配',
contains: '包含匹配',
regex: '正则匹配',
default: '兜底回复'
}[type] || type)
const matchTypeColor = (type) => ({
exact: 'purple',
contains: 'blue',
regex: 'orange',
default: 'geekblue'
}[type] || 'default')
onMounted(() => {
if (route.query.account_id) {
const id = Number(route.query.account_id)
if (Number.isFinite(id)) {
filterAccountId.value = id
// filterAccountId watcher 会触发 fetchRules
} else {
fetchRules()
}
} else {
fetchRules()
}
fetchAccounts()
})
watch(
() => route.query.account_id,
(value) => {
if (value) {
const id = Number(value)
filterAccountId.value = Number.isFinite(id) ? id : null
if (filterAccountId.value) {
cooldownAccountId.value = filterAccountId.value
syncCooldownFromAccount()
}
} else {
filterAccountId.value = null
}
}
)
</script>
<template>
<div class="rules-container">
<div class="page-header glass-card">
<div class="header-info">
<h2 style="margin: 0;">自动回复策略中心</h2>
<p style="color: var(--text-secondary); margin-top: 4px; font-size: 0.9rem;">
每条规则必须绑定一个托管账号仅对该账号生效未配置规则的账号不会自动回复
</p>
</div>
<div class="header-actions">
<a-space size="middle" wrap>
<a-select
v-model:value="filterAccountId"
placeholder="搜索 / 筛选账号"
style="min-width: 200px;"
allow-clear
show-search
:filter-option="filterAccountOption"
:options="accountOptions"
@change="onAccountFilterChange"
>
<template #suffixIcon><FilterOutlined /></template>
</a-select>
<a-button type="primary" class="gradient-btn add-rule-btn" @click="openAddModal">
<template #icon><PlusOutlined /></template>
添加规则
</a-button>
</a-space>
</div>
</div>
<div v-if="filterAccountId" class="filter-banner glass-card">
<div class="filter-banner-text">
<FilterOutlined style="margin-right: 8px;" />
当前查看账号{{ filterAccountLabel }}的规则
</div>
<a-button size="small" @click="clearAccountFilter">查看全部规则</a-button>
</div>
<div class="glass-card cooldown-card" v-if="accounts.length">
<div class="cooldown-title">
<FilterOutlined style="margin-right: 8px;" />
回复冷却时间
</div>
<div class="cooldown-controls">
<a-select
v-model:value="cooldownAccountId"
:options="accountOptions"
style="min-width: 200px;"
placeholder="选择账号"
show-search
:filter-option="filterAccountOption"
@change="onCooldownAccountChange"
/>
<a-input-number
v-model:value="cooldownValue"
:min="0"
:max="86400"
style="width: 220px;"
:placeholder="`留空用全局默认 ${cooldownEffective} 秒`"
/>
<a-button type="primary" class="gradient-btn" :loading="cooldownSaving" @click="saveCooldown">
保存冷却时间
</a-button>
</div>
<div class="cooldown-hint">
同一用户在该时间内无论发送多少条消息都只自动回复一次 0 表示不冷却每条都回复),留空则继承系统全局默认当前 {{ cooldownEffective }} )。与账号编辑里的设置同步保存后重启该账号托管生效
</div>
</div>
<div v-if="!isMobile" class="glass-card table-card" style="margin-top: 24px; padding: 0;">
<a-table
:columns="columns"
:data-source="filteredRules"
:loading="loading"
row-key="id"
:pagination="rulesPagination"
:scroll="{ x: 960 }"
class="custom-table"
@change="handleTableChange"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'match_type'">
<a-tag :color="matchTypeColor(record.match_type)">
{{ matchTypeLabel(record.match_type) }}
</a-tag>
</template>
<template v-if="column.key === 'keyword'">
<span v-if="record.match_type === 'default'" class="keyword-fallback">* 所有未匹配的消息 *</span>
<code v-else>{{ record.keyword }}</code>
</template>
<template v-if="column.key === 'reply_content'">
<div class="reply-content-cell">
<a-tag :color="getReplyTypeColor(record.reply_content)" class="reply-type-tag">
{{ getReplyTypeLabel(record.reply_content) }}
</a-tag>
<a-tag v-if="countReplyMessages(record.reply_content) > 1" color="purple" class="reply-type-tag">
{{ countReplyMessages(record.reply_content) }}
</a-tag>
<span>{{ formatReplyPreview(record.reply_content) }}</span>
</div>
</template>
<template v-if="column.key === 'sort_order'">
<a-space size="small">
<a-button
type="text"
size="small"
class="sort-move-btn"
@click="handleMoveRule(record, 'up')"
>
<template #icon><ArrowUpOutlined /></template>
</a-button>
<a-button
type="text"
size="small"
class="sort-move-btn"
@click="handleMoveRule(record, 'down')"
>
<template #icon><ArrowDownOutlined /></template>
</a-button>
</a-space>
</template>
<template v-if="column.key === 'account_id'">
<a-tag :color="record.account_id === null ? 'default' : 'blue'">
{{ getAccountName(record.account_id) }}
</a-tag>
</template>
<template v-if="column.key === 'is_active'">
<a-switch :checked="record.is_active" @change="handleToggleRule(record)" />
</template>
<template v-if="column.key === 'action'">
<a-space size="middle">
<a-button type="text" style="color: #c084fc;" @click="openEditModal(record)">
<template #icon><EditOutlined /></template>
编辑
</a-button>
<a-popconfirm
title="确定删除此规则吗?"
ok-text="确认"
cancel-text="取消"
@confirm="handleDeleteRule(record.id)"
>
<a-button type="text" danger>
<template #icon><DeleteOutlined /></template>
删除
</a-button>
</a-popconfirm>
</a-space>
</template>
</template>
</a-table>
</div>
<div v-else class="rules-mobile-list">
<a-spin :spinning="loading">
<div v-if="filteredRules.length" class="rule-card-list">
<div v-for="record in filteredRules" :key="record.id" class="rule-card glass-card">
<div class="rule-card-head">
<a-tag :color="matchTypeColor(record.match_type)">
{{ matchTypeLabel(record.match_type) }}
</a-tag>
<a-switch :checked="record.is_active" size="small" @change="handleToggleRule(record)" />
</div>
<div class="rule-card-keyword">
<span v-if="record.match_type === 'default'" class="keyword-fallback">* 所有未匹配的消息 *</span>
<code v-else>{{ record.keyword }}</code>
</div>
<div class="rule-card-reply">
<a-tag :color="getReplyTypeColor(record.reply_content)" class="reply-type-tag">
{{ getReplyTypeLabel(record.reply_content) }}
</a-tag>
<span class="rule-card-reply-text">{{ formatReplyPreview(record.reply_content) }}</span>
</div>
<div class="rule-card-meta">
<a-tag :color="record.account_id === null ? 'default' : 'blue'">
{{ getAccountName(record.account_id) }}
</a-tag>
</div>
<div class="rule-card-actions">
<a-space size="small">
<a-button type="text" size="small" class="sort-move-btn" @click="handleMoveRule(record, 'up')">
<ArrowUpOutlined />
</a-button>
<a-button type="text" size="small" class="sort-move-btn" @click="handleMoveRule(record, 'down')">
<ArrowDownOutlined />
</a-button>
</a-space>
<a-space>
<a-button type="text" style="color: #c084fc;" size="small" @click="openEditModal(record)">
<EditOutlined /> 编辑
</a-button>
<a-popconfirm title="确定删除此规则吗?" ok-text="确认" cancel-text="取消" @confirm="handleDeleteRule(record.id)">
<a-button type="text" danger size="small">
<DeleteOutlined /> 删除
</a-button>
</a-popconfirm>
</a-space>
</div>
</div>
</div>
<div v-else class="rules-empty">暂无规则点击上方添加</div>
<div v-if="rulesTotal > rulesPageSize" class="rules-mobile-pagination">
<a-pagination
:current="rulesPage"
:page-size="rulesPageSize"
:total="rulesTotal"
simple
@change="handleMobilePageChange"
/>
</div>
</a-spin>
</div>
<a-modal
v-model:visible="modalVisible"
:title="modalTitle"
@ok="handleSaveRule"
ok-text="保存"
cancel-text="取消"
:confirm-loading="savingRule"
:ok-button-props="{ class: 'modal-ok-btn' }"
:width="modalWidth"
destroyOnClose
>
<a-form layout="vertical" style="margin-top: 16px;">
<a-form-item label="适用账号" required>
<a-select
v-model:value="ruleForm.account_id"
placeholder="请选择该规则生效的托管账号"
:options="accountOptions"
show-search
:filter-option="filterAccountOption"
:field-names="{ label: 'label', value: 'value' }"
/>
</a-form-item>
<a-form-item label="匹配方式">
<a-radio-group v-model:value="ruleForm.match_type" button-style="solid" class="match-type-group">
<a-radio-button value="contains">包含</a-radio-button>
<a-radio-button value="exact">精确</a-radio-button>
<a-radio-button value="regex">正则</a-radio-button>
<a-radio-button value="default">兜底</a-radio-button>
</a-radio-group>
</a-form-item>
<a-form-item label="匹配关键字" v-if="ruleForm.match_type !== 'default'">
<a-input v-model:value="ruleForm.keyword" placeholder="当对方发来的信息包含此词语时触发回复..." />
</a-form-item>
<ReplyRuleEditor v-model:replies="ruleForm.replies" />
</a-form>
</a-modal>
</div>
</template>
<script>
const columns = [
{
title: '排序',
key: 'sort_order',
width: '90px'
},
{
title: '匹配类型',
dataIndex: 'match_type',
key: 'match_type',
width: '120px'
},
{
title: '触发关键词',
dataIndex: 'keyword',
key: 'keyword',
width: '180px'
},
{
title: '自动回复内容',
dataIndex: 'reply_content',
key: 'reply_content'
},
{
title: '适用账号',
dataIndex: 'account_id',
key: 'account_id',
width: '150px'
},
{
title: '是否启用',
dataIndex: 'is_active',
key: 'is_active',
width: '100px'
},
{
title: '操作',
key: 'action',
width: '180px'
}
]
</script>
<style scoped>
.filter-banner {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 16px;
padding: 12px 16px;
border-radius: 10px;
border: 1px solid rgba(56, 189, 248, 0.25);
background: rgba(56, 189, 248, 0.08);
}
.filter-banner-text {
color: #bae6fd;
font-size: 0.9rem;
}
.cooldown-card {
margin-top: 16px;
padding: 16px 20px;
border-radius: 10px;
}
.cooldown-title {
display: flex;
align-items: center;
color: #fff;
font-weight: 600;
margin-bottom: 12px;
}
.cooldown-controls {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 12px;
}
.cooldown-hint {
margin-top: 10px;
font-size: 0.8rem;
color: var(--text-muted);
line-height: 1.6;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 24px;
flex-wrap: wrap;
gap: 16px;
}
.header-actions {
display: flex;
align-items: center;
}
.gradient-btn {
background: linear-gradient(135deg, var(--primary-color) 0%, var(--accent-pink) 100%) !important;
border: none !important;
height: 40px;
border-radius: 8px;
font-weight: 600;
box-shadow: 0 4px 15px rgba(170, 59, 255, 0.3);
}
.gradient-btn:hover {
opacity: 0.9;
transform: translateY(-1px);
}
.table-card {
overflow: hidden;
border-radius: 12px;
}
.reply-content-cell {
display: flex;
align-items: center;
gap: 8px;
max-width: 420px;
color: #fff;
}
.reply-content-cell span {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.reply-type-tag {
flex-shrink: 0;
}
.keyword-fallback {
color: var(--text-secondary);
font-style: italic;
}
.custom-table :deep(.sort-move-btn) {
color: #c084fc !important;
}
.custom-table :deep(.sort-move-btn:hover:not(:disabled)) {
color: #e9d5ff !important;
background: rgba(192, 132, 252, 0.12) !important;
}
.custom-table :deep(.sort-move-btn:disabled) {
color: rgba(255, 255, 255, 0.25) !important;
}
.custom-table :deep(.ant-tag) {
font-weight: 500;
}
.custom-table :deep(.ant-tag-default) {
color: #e2e8f0 !important;
background: rgba(148, 163, 184, 0.2) !important;
border-color: rgba(203, 213, 225, 0.35) !important;
}
.custom-table code {
color: #e9d5ff;
background: rgba(192, 132, 252, 0.12);
padding: 2px 8px;
border-radius: 4px;
border: 1px solid rgba(192, 132, 252, 0.25);
}
.reply-type-group :deep(.ant-radio-button-wrapper) {
min-width: 88px;
text-align: center;
}
.reply-list-header {
display: flex;
justify-content: space-between;
align-items: center;
margin: 8px 0 12px;
}
.reply-list-title {
font-weight: 600;
color: var(--text-primary);
}
.reply-item-card {
padding: 16px;
margin-bottom: 16px;
border-radius: 10px;
border: 1px solid var(--border-light);
background: rgba(255, 255, 255, 0.02);
}
.reply-item-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
font-weight: 600;
color: #c084fc;
}
.custom-table :deep(.ant-table) {
background: transparent !important;
color: var(--text-primary) !important;
}
.custom-table :deep(.ant-table-thead > tr > th) {
background: rgba(255, 255, 255, 0.03) !important;
color: var(--text-secondary) !important;
border-bottom: 1px solid var(--border-light) !important;
font-weight: 600;
}
.custom-table :deep(.ant-table-tbody > tr > td) {
border-bottom: 1px solid var(--border-light) !important;
background: transparent !important;
transition: background 0.3s;
}
.custom-table :deep(.ant-table-tbody > tr:hover > td) {
background: rgba(255, 255, 255, 0.02) !important;
}
.custom-table :deep(.ant-pagination-item),
.custom-table :deep(.ant-pagination-item-link) {
background: rgba(255, 255, 255, 0.03) !important;
border-color: var(--border-light) !important;
color: var(--text-secondary) !important;
}
.custom-table :deep(.ant-pagination-item-active) {
border-color: var(--primary-color) !important;
}
.custom-table :deep(.ant-pagination-item-active a) {
color: #c084fc !important;
}
:deep(.modal-ok-btn) {
background: linear-gradient(135deg, var(--primary-color) 0%, var(--accent-pink) 100%) !important;
border: none !important;
}
.rules-mobile-list {
margin-top: 16px;
}
.rule-card-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.rule-card {
padding: 16px;
}
.rule-card-head {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 10px;
}
.rule-card-keyword {
margin-bottom: 10px;
word-break: break-all;
}
.rule-card-reply {
display: flex;
flex-wrap: wrap;
align-items: flex-start;
gap: 8px;
margin-bottom: 10px;
}
.rule-card-reply-text {
flex: 1;
min-width: 0;
color: #fff;
font-size: 0.9rem;
line-height: 1.5;
word-break: break-word;
}
.rule-card-meta {
margin-bottom: 12px;
}
.rule-card-actions {
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 8px;
padding-top: 12px;
border-top: 1px solid var(--border-light);
}
.rules-empty {
text-align: center;
padding: 40px 16px;
color: var(--text-muted);
}
.rules-mobile-pagination {
display: flex;
justify-content: center;
margin-top: 16px;
}
@media (max-width: 768px) {
.page-header {
flex-direction: column;
align-items: stretch;
padding: 16px;
gap: 12px;
}
.add-rule-btn {
width: 100%;
}
.filter-banner {
flex-direction: column;
align-items: stretch;
gap: 10px;
}
.cooldown-card {
padding: 14px 16px;
}
.cooldown-controls {
flex-direction: column;
align-items: stretch;
}
.cooldown-controls :deep(.ant-select),
.cooldown-controls :deep(.ant-input-number),
.cooldown-controls .gradient-btn {
width: 100% !important;
}
.match-type-group :deep(.ant-radio-button-wrapper) {
min-width: 0;
flex: 1;
padding-inline: 8px;
font-size: 0.85rem;
}
.match-type-group {
display: flex;
width: 100%;
}
.match-type-group :deep(.ant-radio-button-wrapper) {
text-align: center;
}
}
</style>