1069 lines
28 KiB
Vue
1069 lines
28 KiB
Vue
<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>
|