feat(force-onboarding): 用户列表加「开启新手引导」一次性操作 (#13)

Co-authored-by: zzhyyyyy <2685922758@qq.com>
Reviewed-on: #13
Co-authored-by: zhuzihao <zhuzihao@wonderable.ai>
Co-committed-by: zhuzihao <zhuzihao@wonderable.ai>
This commit was merged in pull request #13.
This commit is contained in:
2026-06-21 00:15:17 +08:00
committed by marco
parent 98417ff8c6
commit 36d00a9d68
12 changed files with 643 additions and 200 deletions
@@ -0,0 +1,273 @@
'use client';
import { useEffect, useState } from 'react';
import { Button, Card, Image, Input, Space, Spin, Switch, Tag, Typography, Upload, message } from 'antd';
import { DeleteOutlined, UploadOutlined } from '@ant-design/icons';
import { api, errMsg } from '@/lib/api';
import { canDo } from '@/lib/auth';
import { mediaUrl } from '@/lib/media';
import type { FeedbackQrConfig as QrConfig } from '@/lib/types';
const { Text } = Typography;
const ACCEPT = ['image/jpeg', 'image/png', 'image/webp'];
/** 反馈页「加群二维码」卡配置。改完 App 意见反馈页下次进入即同步。「反馈工单」页的一个区块。 */
export default function FeedbackQrConfig() {
const [cfg, setCfg] = useState<QrConfig | null>(null);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [uploading, setUploading] = useState(false);
// 本地编辑态(文案 + 开关),保存时一次性 PATCH
const [enabled, setEnabled] = useState(true);
const [title, setTitle] = useState('');
const [groupName, setGroupName] = useState('');
const [subtitle, setSubtitle] = useState('');
const canEdit = canDo(['operator']);
const sync = (c: QrConfig) => {
setCfg(c);
setEnabled(c.enabled);
setTitle(c.title);
setGroupName(c.group_name);
setSubtitle(c.subtitle);
};
const load = async () => {
setLoading(true);
try {
const { data } = await api.get<QrConfig>('/admin/api/feedback-config');
sync(data);
} finally {
setLoading(false);
}
};
useEffect(() => {
load();
}, []);
const save = async () => {
if (!title.trim() || !groupName.trim() || !subtitle.trim()) {
message.warning('三行文案都需填写');
return;
}
setSaving(true);
try {
const { data } = await api.patch<QrConfig>('/admin/api/feedback-config', {
enabled,
title: title.trim(),
group_name: groupName.trim(),
subtitle: subtitle.trim(),
});
sync(data);
message.success('已保存,App 反馈页下次进入即可见');
} catch (e) {
message.error(errMsg(e));
} finally {
setSaving(false);
}
};
// antd Upload:beforeUpload 里自行 POST(multipart),返回 false 阻止其默认上传。
const beforeUpload = (file: File) => {
if (!ACCEPT.includes(file.type)) {
message.error('仅支持 JPEG / PNG / WebP 图片');
return Upload.LIST_IGNORE;
}
if (file.size > 5 * 1024 * 1024) {
message.error('图片不能超过 5MB');
return Upload.LIST_IGNORE;
}
void uploadImage(file);
return false;
};
const uploadImage = async (file: File) => {
const form = new FormData();
form.append('file', file);
setUploading(true);
try {
const { data } = await api.post<QrConfig>('/admin/api/feedback-config/image', form);
sync(data);
message.success('二维码已更新,App 反馈页下次进入即可见');
} catch (e) {
message.error(errMsg(e));
} finally {
setUploading(false);
}
};
const removeImage = async () => {
setUploading(true);
try {
const { data } = await api.delete<QrConfig>('/admin/api/feedback-config/image');
sync(data);
message.success('已移除二维码,App 将显示本地兜底图');
} catch (e) {
message.error(errMsg(e));
} finally {
setUploading(false);
}
};
return (
<Card
size="small"
title="反馈页二维码卡(App 意见反馈页底部)"
style={{ marginBottom: 16 }}
extra={
cfg?.updated_at ? (
<Text type="secondary" style={{ fontSize: 12 }}>
{new Date(cfg.updated_at).toLocaleString('zh-CN')}
</Text>
) : null
}
>
<p style={{ color: '#999', marginTop: 0 }}>
App
</p>
{loading || !cfg ? (
<Spin style={{ display: 'block', margin: '24px 0' }} />
) : (
<Space align="start" size={32} wrap>
{/* 左:所见即所得预览(对齐 App 卡片布局) */}
<div>
<Text type="secondary" style={{ fontSize: 12 }}>
App
</Text>
<div
style={{
marginTop: 8,
width: 320,
display: 'flex',
gap: 14,
alignItems: 'center',
padding: 14,
border: '1px solid #f0f0f0',
borderRadius: 14,
boxShadow: '0 1px 4px rgba(0,0,0,0.06)',
opacity: enabled ? 1 : 0.4,
}}
>
{cfg.image_url ? (
<Image
src={mediaUrl(cfg.image_url)}
width={88}
height={88}
style={{ objectFit: 'cover', borderRadius: 8, border: '1px solid #f0f0f0' }}
/>
) : (
<div
style={{
width: 88,
height: 88,
borderRadius: 8,
border: '1px dashed #d9d9d9',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: '#bbb',
fontSize: 12,
textAlign: 'center',
flexShrink: 0,
}}
>
</div>
)}
<div style={{ fontSize: 13, color: '#1a1a1a', lineHeight: '21px' }}>
<div>{title || '(标题)'}</div>
<div>
<b>{groupName || '群名'}</b>
</div>
<div>{subtitle || '(标语)'}</div>
</div>
</div>
{!enabled && (
<div style={{ color: '#fa8c16', fontSize: 12, marginTop: 6 }}>
App
</div>
)}
</div>
{/* 右:编辑控件 */}
<Space direction="vertical" size="middle" style={{ minWidth: 320 }}>
<Space>
<span></span>
<Switch checked={enabled} disabled={!canEdit} onChange={setEnabled} />
{!enabled && <Tag color="orange"></Tag>}
</Space>
<Space wrap>
<Upload accept="image/*" showUploadList={false} beforeUpload={beforeUpload} disabled={!canEdit}>
<Button icon={<UploadOutlined />} loading={uploading} disabled={!canEdit}>
{cfg.image_url ? '更换二维码' : '上传二维码'}
</Button>
</Upload>
{cfg.image_url && (
<Button
icon={<DeleteOutlined />}
danger
loading={uploading}
disabled={!canEdit}
onClick={removeImage}
>
</Button>
)}
<Text type="secondary" style={{ fontSize: 12 }}>
JPEG/PNG/WebP5MB
</Text>
</Space>
<div>
<Text type="secondary" style={{ fontSize: 12 }}>
</Text>
<Input
value={title}
maxLength={40}
disabled={!canEdit}
onChange={(e) => setTitle(e.target.value)}
placeholder="如:长按图片保存二维码"
/>
</div>
<div>
<Text type="secondary" style={{ fontSize: 12 }}>
App
</Text>
<Input
value={groupName}
maxLength={40}
disabled={!canEdit}
onChange={(e) => setGroupName(e.target.value)}
placeholder="如:傻瓜比价官方群"
/>
</div>
<div>
<Text type="secondary" style={{ fontSize: 12 }}>
</Text>
<Input
value={subtitle}
maxLength={60}
disabled={!canEdit}
onChange={(e) => setSubtitle(e.target.value)}
placeholder="如:一起唠嗑共创、解锁新玩法"
/>
</div>
<Button type="primary" loading={saving} disabled={!canEdit} onClick={save}>
/
</Button>
{!canEdit && <Text type="secondary"> operator / super_admin </Text>}
</Space>
</Space>
)}
</Card>
);
}