fix(users): restore WeChat template notification flow

This commit is contained in:
kate522 committed 2026-11-10 20:54:24 +08:00
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>