fix(users): restore WeChat template notification flow
This commit is contained in:
1 parent
30ceab4615
commit
7fe26d3fff
18 files changed
+1051
-763
No files matched your search
@@ -1,257 +1,198 @@
|
||||
<template>
|
||||
<div class="jio-page-container">
|
||||
<PageHeader title="微信模板消息" desc="配置微信公众号模板消息推送。" />
|
||||
<PageHeader title="微信模板消息" desc="按通知事件配置公众号模板及参数。" />
|
||||
|
||||
<!-- 状态卡片 -->
|
||||
<div class="jio-wechat__status">
|
||||
<div class="jio-wechat__status-card">
|
||||
<div class="jio-wechat__status-icon" :class="status.configured ? 'jio-wechat__status-icon--ok' : 'jio-wechat__status-icon--warn'">
|
||||
<i :class="status.configured ? 'fa fas fa-check' : 'fa fas fa-exclamation'"></i>
|
||||
</div>
|
||||
<div class="jio-wechat__status-info">
|
||||
<div class="jio-wechat__status-label">配置状态</div>
|
||||
<div class="jio-wechat__status-value">{{ status.configured ? '已配置' : '未配置' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="jio-wechat__status-card">
|
||||
<div class="jio-wechat__status-icon" :class="status.has_token ? 'jio-wechat__status-icon--ok' : 'jio-wechat__status-icon--warn'">
|
||||
<i :class="status.has_token ? 'fa fas fa-key' : 'fa fas fa-key'"></i>
|
||||
</div>
|
||||
<div class="jio-wechat__status-info">
|
||||
<div class="jio-wechat__status-label">Access Token</div>
|
||||
<div class="jio-wechat__status-value">{{ status.has_token ? '已获取' : '未获取' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="jio-wechat__status-card">
|
||||
<div class="jio-wechat__status-icon" :class="status.has_template ? 'jio-wechat__status-icon--ok' : 'jio-wechat__status-icon--warn'">
|
||||
<i :class="status.has_template ? 'fa fas fa-file-alt' : 'fa fas fa-file-alt'"></i>
|
||||
</div>
|
||||
<div class="jio-wechat__status-info">
|
||||
<div class="jio-wechat__status-label">模板 ID</div>
|
||||
<div class="jio-wechat__status-value">{{ status.has_template ? '已配置' : '未配置' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="jio-wechat__status-card">
|
||||
<div class="jio-wechat__status-icon jio-wechat__status-icon--info">
|
||||
<i class="fa fas fa-users"></i>
|
||||
</div>
|
||||
<div class="jio-wechat__status-info">
|
||||
<div class="jio-wechat__status-label">绑定用户</div>
|
||||
<div class="jio-wechat__status-value">{{ status.bound_users || 0 }} 人</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<n-spin :show="loading" style="width: 100%; min-height: 240px">
|
||||
<n-alert v-if="loadError" type="error" class="wechat-notice">
|
||||
{{ loadError }}
|
||||
<template #action><n-button size="small" @click="load">重试</n-button></template>
|
||||
</n-alert>
|
||||
<template v-else>
|
||||
<n-alert :type="oauthReady ? 'info' : 'warning'" class="wechat-notice">
|
||||
<div>模板消息依赖已启用的微信公众号登录服务号,以及公众号的模板消息权限。</div>
|
||||
<div>只有绑定并关注该公众号的用户才能收到通知。当前账号:{{ currentUserBound ? '已绑定' : '未绑定' }};已绑定用户:{{ boundUsers }} 人。</div>
|
||||
<template #action>
|
||||
<router-link to="/users/oauth">公众号登录设置</router-link>
|
||||
</template>
|
||||
</n-alert>
|
||||
|
||||
<!-- 配置表单 -->
|
||||
<div class="jio-wechat__form-card">
|
||||
<h3>基本配置</h3>
|
||||
<div class="jio-form-group">
|
||||
<label>
|
||||
<input type="checkbox" v-model="form.enabled"> 启用微信模板消息
|
||||
</label>
|
||||
</div>
|
||||
<div class="jio-form-group">
|
||||
<label>AppID</label>
|
||||
<input v-model="form.app_id" type="text" class="jio-input" placeholder="微信公众号 AppID">
|
||||
</div>
|
||||
<div class="jio-form-group">
|
||||
<label>AppSecret</label>
|
||||
<input v-model="form.app_secret" type="text" class="jio-input jio-secret-input" placeholder="微信公众号 AppSecret">
|
||||
<p class="jio-form-hint">保存后不会回显完整密钥</p>
|
||||
</div>
|
||||
<div class="jio-form-group">
|
||||
<label>模板 ID</label>
|
||||
<input v-model="form.template_id" type="text" class="jio-input" placeholder="微信模板消息模板 ID">
|
||||
</div>
|
||||
<div class="jio-form-actions">
|
||||
<button class="jio-button jio-button--primary" :disabled="saving" @click="saveSettings">
|
||||
{{ saving ? '保存中...' : '保存设置' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<section class="wechat-section">
|
||||
<div class="wechat-section__head">
|
||||
<h3>消息推送</h3>
|
||||
<n-switch v-model:value="form.enabled" :disabled="loading" aria-label="启用微信模板消息" />
|
||||
</div>
|
||||
<p class="wechat-muted">通过微信公众号将网站通知推送给绑定用户。配置模板后保存,测试会发送到当前管理员账号。</p>
|
||||
</section>
|
||||
|
||||
<!-- 通知类型 -->
|
||||
<div class="jio-wechat__form-card">
|
||||
<h3>支持的通知类型</h3>
|
||||
<p class="jio-form-hint">以下事件类型可触发微信模板消息推送(需用户绑定微信且开启对应订阅):</p>
|
||||
<table class="jio-table jio-table--sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>事件类型</th>
|
||||
<th>说明</th>
|
||||
<th>状态</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="evt in eventTypes" :key="evt.key">
|
||||
<td><code>{{ evt.key }}</code></td>
|
||||
<td>{{ evt.desc }}</td>
|
||||
<td>
|
||||
<span class="jio-badge" :class="evt.ready ? 'jio-badge--success' : 'jio-badge--warning'">
|
||||
{{ evt.ready ? '可用' : '待接入' }}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<section v-for="group in groups" :key="group.name" class="wechat-section">
|
||||
<h3>{{ group.name }}</h3>
|
||||
<div v-for="type in group.types" :key="type.key" class="wechat-template">
|
||||
<div class="wechat-template__head">
|
||||
<div>
|
||||
<strong>{{ type.label }}</strong>
|
||||
<span class="wechat-template__key">{{ type.key }}</span>
|
||||
</div>
|
||||
<n-switch v-model:value="form.templates[type.key].enabled" :disabled="!form.enabled" :aria-label="`启用${type.label}`" />
|
||||
</div>
|
||||
<div v-if="form.enabled && form.templates[type.key].enabled" class="wechat-template__body">
|
||||
<label class="wechat-field">
|
||||
<span>模板 ID</span>
|
||||
<n-input v-model:value="form.templates[type.key].id" placeholder="公众号模板消息 ID" maxlength="128" clearable />
|
||||
</label>
|
||||
<div class="wechat-field">
|
||||
<span>模板参数</span>
|
||||
<div class="wechat-params">
|
||||
<label v-for="field in type.fields" :key="field.key" class="wechat-param">
|
||||
<span>{{ field.label }} <code>{{ field.key }}</code></span>
|
||||
<n-input v-model:value="form.templates[type.key].keys[field.key]" :placeholder="`留空使用 keyword${field.index}`" maxlength="64" clearable />
|
||||
</label>
|
||||
</div>
|
||||
<small>填写公众号模板中的参数名,例如 thing1、time2;留空时依次使用 keyword1、keyword2。</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="jio-form-actions">
|
||||
<n-button :disabled="!dirty || saving" @click="load">放弃更改</n-button>
|
||||
<n-button type="primary" :disabled="!dirty || loading" :loading="saving" @click="save">保存设置</n-button>
|
||||
</div>
|
||||
|
||||
<section class="wechat-section wechat-test">
|
||||
<h3>发送测试</h3>
|
||||
<p class="wechat-muted">先保存配置,再选择已启用的模板。测试消息会发送到当前管理员绑定的公众号账号。</p>
|
||||
<div class="wechat-test__controls">
|
||||
<n-select v-model:value="testType" :options="testOptions" placeholder="选择需要测试的模板" :disabled="!testOptions.length" />
|
||||
<n-button type="primary" :loading="testing" :disabled="dirty || !testType || !currentUserBound || !oauthReady || !form.enabled" @click="sendTest">发送测试</n-button>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
</n-spin>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { ref, reactive, computed, onMounted } from 'vue';
|
||||
import PageHeader from '@/components/PageHeader.vue';
|
||||
import api from '@/api';
|
||||
<script setup>
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { useMessage } from 'naive-ui'
|
||||
import PageHeader from '@/components/PageHeader.vue'
|
||||
import api from '@/api'
|
||||
|
||||
export default {
|
||||
name: 'UsersWechatTemplate',
|
||||
components: { PageHeader },
|
||||
setup() {
|
||||
const status = ref({
|
||||
enabled: false,
|
||||
configured: false,
|
||||
has_token: false,
|
||||
has_template: false,
|
||||
bound_users: 0,
|
||||
});
|
||||
const message = useMessage()
|
||||
const loading = ref(true)
|
||||
const saving = ref(false)
|
||||
const testing = ref(false)
|
||||
const loadError = ref('')
|
||||
const oauthReady = ref(false)
|
||||
const currentUserBound = ref(false)
|
||||
const boundUsers = ref(0)
|
||||
const definitions = ref({})
|
||||
const form = reactive({ enabled: false, templates: {} })
|
||||
const initial = ref('')
|
||||
const testType = ref(null)
|
||||
const snapshot = computed(() => JSON.stringify(form))
|
||||
const dirty = computed(() => initial.value !== '' && initial.value !== snapshot.value)
|
||||
|
||||
const form = reactive({
|
||||
enabled: false,
|
||||
app_id: '',
|
||||
app_secret: '',
|
||||
template_id: '',
|
||||
});
|
||||
const groups = computed(() => {
|
||||
const grouped = new Map()
|
||||
for (const [key, definition] of Object.entries(definitions.value)) {
|
||||
const name = definition.group || '其他'
|
||||
if (!grouped.has(name)) grouped.set(name, [])
|
||||
grouped.get(name).push({
|
||||
key,
|
||||
label: definition.label,
|
||||
fields: Object.entries(definition.fields || {}).map(([fieldKey, label], index) => ({ key: fieldKey, label, index: index + 1 })),
|
||||
})
|
||||
}
|
||||
return Array.from(grouped, ([name, types]) => ({ name, types }))
|
||||
})
|
||||
|
||||
const saving = ref(false);
|
||||
const testOptions = computed(() => Object.entries(definitions.value)
|
||||
.filter(([key]) => form.templates[key]?.enabled && form.templates[key]?.id)
|
||||
.map(([key, definition]) => ({ label: definition.label, value: key })))
|
||||
|
||||
// Event type definitions — readiness driven by backend allowed_types
|
||||
const eventTypeDefs = [
|
||||
{ key: 'comment_reply', desc: '评论回复通知' },
|
||||
{ key: 'system_notice', desc: '系统公告通知' },
|
||||
{ key: 'order_paid', desc: '订单支付通知' },
|
||||
{ key: 'credits_change', desc: '积分变动通知' },
|
||||
];
|
||||
|
||||
const allowedTypes = ref([]);
|
||||
|
||||
const eventTypes = computed(() =>
|
||||
eventTypeDefs.map(et => ({
|
||||
...et,
|
||||
ready: allowedTypes.value.length === 0 || allowedTypes.value.includes(et.key),
|
||||
}))
|
||||
);
|
||||
|
||||
async function loadStatus() {
|
||||
try {
|
||||
const json = await api.request('/wechat/status');
|
||||
if (json.success) {
|
||||
status.value = json.data;
|
||||
form.enabled = json.data.enabled || false;
|
||||
form.app_id = json.data.appid || '';
|
||||
form.app_secret = json.data.app_secret === '***' ? '' : '';
|
||||
form.template_id = json.data.template_id || '';
|
||||
// Use backend allowed_types for readiness (empty = all allowed)
|
||||
allowedTypes.value = json.data.allowed_types || [];
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Load WeChat status failed:', e);
|
||||
}
|
||||
async function load() {
|
||||
loading.value = true
|
||||
loadError.value = ''
|
||||
try {
|
||||
const response = await api.request('/wechat/status')
|
||||
const data = response.data || {}
|
||||
definitions.value = data.types || {}
|
||||
form.enabled = Boolean(data.config?.enabled)
|
||||
form.templates = structuredClone(data.config?.templates || {})
|
||||
oauthReady.value = Boolean(data.oauth_ready)
|
||||
currentUserBound.value = Boolean(data.current_user_bound)
|
||||
boundUsers.value = Number(data.bound_users || 0)
|
||||
if (!testOptions.value.some(option => option.value === testType.value)) {
|
||||
testType.value = testOptions.value[0]?.value || null
|
||||
}
|
||||
initial.value = snapshot.value
|
||||
} catch (error) {
|
||||
loadError.value = error.message || '加载微信模板消息设置失败'
|
||||
message.error(loadError.value)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function saveSettings() {
|
||||
saving.value = true;
|
||||
try {
|
||||
const payload = { ...form };
|
||||
// 如果密码为空且状态显示已配置,发送掩码保留原值
|
||||
if (!payload.app_secret && status.value.configured) {
|
||||
payload.app_secret = '***';
|
||||
}
|
||||
const json = await api.request('/wechat/settings', {
|
||||
method: 'POST',
|
||||
data: payload,
|
||||
});
|
||||
if (json.success) {
|
||||
alert('设置已保存');
|
||||
loadStatus();
|
||||
} else {
|
||||
alert(json.message || '保存失败');
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Save failed:', e);
|
||||
alert('保存失败: ' + (e.message || '未知错误'));
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
async function save() {
|
||||
saving.value = true
|
||||
try {
|
||||
const response = await api.request('/wechat/settings', { method: 'POST', data: JSON.parse(snapshot.value) })
|
||||
const config = response.data || {}
|
||||
form.enabled = Boolean(config.enabled)
|
||||
form.templates = structuredClone(config.templates || {})
|
||||
if (!testOptions.value.some(option => option.value === testType.value)) {
|
||||
testType.value = testOptions.value[0]?.value || null
|
||||
}
|
||||
initial.value = snapshot.value
|
||||
message.success('微信模板消息设置已保存')
|
||||
} catch (error) {
|
||||
message.error(error.message || '保存失败')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => loadStatus());
|
||||
async function sendTest() {
|
||||
testing.value = true
|
||||
try {
|
||||
await api.request('/wechat/test', { method: 'POST', data: { type: testType.value } })
|
||||
message.success('测试消息已发送')
|
||||
} catch (error) {
|
||||
message.error(error.message || '测试发送失败')
|
||||
} finally {
|
||||
testing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
return { status, form, saving, eventTypes, saveSettings };
|
||||
},
|
||||
};
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.jio-wechat__status {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 16px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.jio-wechat__status-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #e8e8e8;
|
||||
}
|
||||
.jio-wechat__status-icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 16px;
|
||||
}
|
||||
.jio-wechat__status-icon--ok {
|
||||
background: #d4edda;
|
||||
color: #155724;
|
||||
}
|
||||
.jio-wechat__status-icon--warn {
|
||||
background: #fff3cd;
|
||||
color: #856404;
|
||||
}
|
||||
.jio-wechat__status-icon--info {
|
||||
background: #d1ecf1;
|
||||
color: #0c5460;
|
||||
}
|
||||
.jio-wechat__status-value {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: var(--jio-color-text);
|
||||
}
|
||||
.jio-wechat__status-label {
|
||||
font-size: 12px;
|
||||
color: #888;
|
||||
}
|
||||
.jio-wechat__form-card {
|
||||
background: #fff;
|
||||
border: 1px solid #e8e8e8;
|
||||
border-radius: 8px;
|
||||
padding: 24px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.jio-wechat__form-card h3 {
|
||||
margin: 0 0 16px;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.jio-form-hint {
|
||||
font-size: 12px;
|
||||
color: #888;
|
||||
margin-top: 4px;
|
||||
.wechat-notice { margin-bottom: 20px; }
|
||||
.wechat-section { border-top: 1px solid #e6e9ed; padding: 20px 0; }
|
||||
.wechat-section h3 { margin: 0 0 12px; font-size: 16px; font-weight: 600; }
|
||||
.wechat-section__head { display: flex; align-items: center; justify-content: space-between; gap: 16px; }
|
||||
.wechat-section__head h3 { margin: 0; }
|
||||
.wechat-muted { margin: 8px 0 0; color: #64707d; font-size: 13px; line-height: 1.6; }
|
||||
.wechat-template { border-bottom: 1px solid #edf0f2; padding: 14px 0; }
|
||||
.wechat-template:last-child { border-bottom: 0; }
|
||||
.wechat-template__head { display: flex; align-items: center; justify-content: space-between; gap: 16px; min-height: 32px; }
|
||||
.wechat-template__head strong { font-size: 14px; font-weight: 600; }
|
||||
.wechat-template__key { display: block; color: #7b8590; font-size: 11px; margin-top: 3px; }
|
||||
.wechat-template__body { display: grid; grid-template-columns: minmax(200px, 320px) minmax(0, 1fr); gap: 24px; padding: 16px 0 4px; }
|
||||
.wechat-field { display: flex; flex-direction: column; gap: 7px; min-width: 0; font-size: 13px; }
|
||||
.wechat-field > span { font-weight: 600; }
|
||||
.wechat-field small { color: #64707d; line-height: 1.5; }
|
||||
.wechat-params { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 12px; }
|
||||
.wechat-param { display: flex; flex-direction: column; gap: 5px; min-width: 0; }
|
||||
.wechat-param span { color: #4c5966; font-size: 12px; }
|
||||
.wechat-param code { color: #7b8590; font-size: 11px; }
|
||||
.wechat-test__controls { display: flex; gap: 10px; margin-top: 14px; max-width: 520px; }
|
||||
.wechat-test__controls :deep(.n-select) { flex: 1; min-width: 0; }
|
||||
@media (max-width: 780px) {
|
||||
.wechat-template__body { grid-template-columns: 1fr; gap: 16px; }
|
||||
.wechat-test__controls { flex-wrap: wrap; }
|
||||
.wechat-test__controls :deep(.n-select) { flex-basis: 100%; }
|
||||
}
|
||||
</style>
|
||||
Reference in new issue
Block a user