Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 13 additions & 6 deletions controller/feedback.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,9 +38,10 @@ func GetUserFeedbackTopics(c *gin.Context) {
userId := c.GetInt("id")
status, _ := strconv.Atoi(c.DefaultQuery("status", "0"))
category, _ := strconv.Atoi(c.DefaultQuery("category", "0"))
keyword := strings.TrimSpace(c.Query("keyword"))
page, pageSize := parsePaging(c, feedbackDefaultPageSize, feedbackMaxTopicPageSize)

topics, total, err := model.GetUserFeedbackTopics(userId, status, category, page, pageSize)
topics, total, err := model.GetUserFeedbackTopics(userId, status, category, keyword, page, pageSize)
if err != nil {
common.ApiErrorMsg(c, "查询失败")
return
Expand Down Expand Up @@ -102,7 +103,7 @@ func GetUserFeedbackTopicDetail(c *gin.Context) {
}
model.MarkFeedbackUserRead(id, userId)
topic.UserUnread = false
feedbackWriteDetail(c, topic, "")
feedbackWriteDetail(c, topic, "", true)
}

// ReplyFeedbackTopic POST /api/user/feedback/topics/:id/messages
Expand Down Expand Up @@ -203,7 +204,7 @@ func AdminGetFeedbackTopicDetail(c *gin.Context) {
if names := feedbackUsernames([]int{topic.UserId}); names != nil {
username = names[topic.UserId]
}
feedbackWriteDetail(c, topic, username)
feedbackWriteDetail(c, topic, username, false)
}

// AdminReplyFeedbackTopic POST /api/user/feedback/admin/topics/:id/messages
Expand Down Expand Up @@ -300,16 +301,22 @@ func feedbackAddMessage(c *gin.Context, topicId, authorId, authorRole int) {
}

// feedbackWriteDetail 读取消息分页并输出工单详情。
func feedbackWriteDetail(c *gin.Context, topic *model.FeedbackTopic, username string) {
// maskAdmin=true(用户侧)时隐去管理员消息的真名与 user_id,统一「官方客服」。
func feedbackWriteDetail(c *gin.Context, topic *model.FeedbackTopic, username string, maskAdmin bool) {
page, pageSize := parsePaging(c, feedbackMsgPageSize, feedbackMaxMsgPageSize)
messages, total, err := model.GetFeedbackMessages(topic.Id, page, pageSize)
messages, total, err := model.GetFeedbackMessages(topic.Id, page, pageSize, maskAdmin)
if err != nil {
common.ApiErrorMsg(c, "查询失败")
return
}
items := make([]dto.FeedbackMessageItem, 0, len(messages))
for _, m := range messages {
items = append(items, feedbackMessageToItem(m))
item := feedbackMessageToItem(m)
// 用户侧脱敏:管理员消息不暴露具体管理员 user_id(前端按空名显示「官方客服」)。
if maskAdmin && m.AuthorRole == model.FeedbackAuthorAdmin {
item.AuthorId = 0
}
items = append(items, item)
}
common.ApiSuccess(c, dto.FeedbackTopicDetailResponse{
Topic: feedbackTopicToItem(topic, username),
Expand Down
96 changes: 75 additions & 21 deletions docs/feedback-consult-design.md

Large diffs are not rendered by default.

13 changes: 11 additions & 2 deletions model/feedback.go
Original file line number Diff line number Diff line change
Expand Up @@ -336,14 +336,17 @@ func GetFeedbackTopicById(topicId int) (*FeedbackTopic, error) {
}

// GetUserFeedbackTopics 我的工单列表,按 last_reply_at DESC(最新置顶)。status/category=0 表示不过滤。
func GetUserFeedbackTopics(userId, status, category, page, pageSize int) ([]*FeedbackTopic, int64, error) {
func GetUserFeedbackTopics(userId, status, category int, keyword string, page, pageSize int) ([]*FeedbackTopic, int64, error) {
query := DB.Model(&FeedbackTopic{}).Where("user_id = ?", userId)
if status != 0 {
query = query.Where("status = ?", status)
}
if category != 0 {
query = query.Where("category = ?", category)
}
if keyword != "" {
query = query.Where("title LIKE ?", "%"+keyword+"%")
}

var total int64
if err := query.Count(&total).Error; err != nil {
Expand Down Expand Up @@ -406,7 +409,9 @@ func GetFeedbackAdminTopics(filterUserId, status, category int, username, keywor
}

// GetFeedbackMessages 取某工单的消息(分页,created_at ASC),并回填 ImageIds 与 AuthorName。
func GetFeedbackMessages(topicId, page, pageSize int) ([]*FeedbackMessage, int64, error) {
// maskAdmin=true(用户侧)时不回填管理员消息的真名,避免向终端用户暴露管理员账号
// (撞库/猜密码风险,见 docs/feedback-consult-design.md §2.2);控制器再把 AuthorId 置 0。
func GetFeedbackMessages(topicId, page, pageSize int, maskAdmin bool) ([]*FeedbackMessage, int64, error) {
query := DB.Model(&FeedbackMessage{}).Where("topic_id = ?", topicId)

var total int64
Expand Down Expand Up @@ -440,6 +445,10 @@ func GetFeedbackMessages(topicId, page, pageSize int) ([]*FeedbackMessage, int64
nameMap := loadUsernames(authorIds)
for _, m := range messages {
m.ImageIds = imgMap[m.Id]
// 用户侧脱敏:管理员消息不回填真名(前端固定显示「官方客服」)。
if maskAdmin && m.AuthorRole == FeedbackAuthorAdmin {
continue
}
m.AuthorName = nameMap[m.UserId]
}
return messages, total, nil
Expand Down
9 changes: 9 additions & 0 deletions web/classic/src/App.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ import PersonalSetting from './components/settings/PersonalSetting';
import KYCPage from './pages/KYC';
import EnterprisePage from './pages/Enterprise';
import FeedbackPage from './pages/Feedback';
import MyFeedbackPage from './pages/Feedback/MyFeedback';
import Setup from './pages/Setup';
import SetupCheck from './components/layout/SetupCheck';

Expand Down Expand Up @@ -207,6 +208,14 @@ function App() {
</AdminRoute>
}
/>
<Route
path='/console/myfeedback'
element={
<PrivateRoute>
<MyFeedbackPage />
</PrivateRoute>
}
/>
<Route
path='/user/reset'
element={
Expand Down
56 changes: 41 additions & 15 deletions web/classic/src/components/feedback/FeedbackThread.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import { IconImage, IconClose, IconSend } from '@douyinfe/semi-icons';
import { API, showError } from '../../helpers';
import { useTranslation } from 'react-i18next';
import {
compressImageToBase64,
encodeFeedbackImageFiles,
FEEDBACK_MAX_IMAGES,
FEEDBACK_ROLE_ADMIN,
} from './feedbackHelpers';
Expand Down Expand Up @@ -121,31 +121,40 @@ export default function FeedbackThread({
const { t } = useTranslation();
const [content, setContent] = useState('');
const [images, setImages] = useState([]); // base64[]
const [dragging, setDragging] = useState(false);
const fileRef = useRef(null);
const endRef = useRef(null);

useEffect(() => {
endRef.current?.scrollIntoView({ block: 'end' });
}, [messages]);

// 点击选择与拖拽共用:处理一批文件 → 追加到 images。
const addFiles = async (fileList) => {
const { encoded, error } = await encodeFeedbackImageFiles(
fileList,
images.length,
);
if (error) showError(t(error));
// 函数式封顶:即使并发拖拽/选择读到的是旧 count,也保证不超过上限。
if (encoded.length)
setImages((prev) => [...prev, ...encoded].slice(0, FEEDBACK_MAX_IMAGES));
};

const handleFiles = async (e) => {
const files = Array.from(e.target.files || []);
const fileList = e.target.files;
e.target.value = '';
if (files.length === 0) return;
const room = FEEDBACK_MAX_IMAGES - images.length;
if (room <= 0) {
await addFiles(fileList);
};

const handleDrop = async (e) => {
e.preventDefault();
setDragging(false);
if (images.length >= FEEDBACK_MAX_IMAGES) {
showError(t('最多上传 3 张图片'));
return;
}
try {
const picked = files.slice(0, room);
const encoded = await Promise.all(
picked.map((f) => compressImageToBase64(f)),
);
setImages((prev) => [...prev, ...encoded]);
} catch {
showError(t('图片处理失败'));
}
await addFiles(e.dataTransfer.files);
};

const submit = async () => {
Expand Down Expand Up @@ -184,7 +193,24 @@ export default function FeedbackThread({
</div>

{!disabled && (
<div className='border-t pt-2'>
<div
className={`border-t pt-2 rounded-md transition-colors ${
dragging ? 'bg-blue-50 ring-2 ring-blue-300 ring-inset' : ''
}`}
onDragOver={(e) => {
e.preventDefault();
if (!dragging) setDragging(true);
}}
onDragLeave={(e) => {
if (!e.currentTarget.contains(e.relatedTarget)) setDragging(false);
}}
onDrop={handleDrop}
>
{dragging && (
<div className='mb-2 text-center text-xs text-blue-500'>
{t('松开鼠标上传图片')}
</div>
)}
{images.length > 0 && (
<div className='flex flex-wrap gap-2 mb-2'>
{images.map((b64, idx) => (
Expand Down
20 changes: 20 additions & 0 deletions web/classic/src/components/feedback/feedbackHelpers.js
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,26 @@ export const FEEDBACK_CATEGORY_OPTIONS = Object.entries(FEEDBACK_CATEGORY).map(
([value, { label }]) => ({ value: Number(value), label }),
);

// 把「选择」或「拖拽」进来的文件统一处理成 base64[]:自动过滤非图片、按剩余
// 配额(FEEDBACK_MAX_IMAGES - currentCount)裁剪、逐张压缩。点击上传与拖拽上传共用。
// 返回 { encoded, error };error 为待 t() 翻译的文案 key(无错误则 undefined)。
export async function encodeFeedbackImageFiles(fileList, currentCount) {
const files = Array.from(fileList || []).filter((f) =>
f.type ? f.type.startsWith('image/') : true,
);
if (files.length === 0) return { encoded: [] };
const room = FEEDBACK_MAX_IMAGES - currentCount;
if (room <= 0) return { encoded: [], error: '最多上传 3 张图片' };
try {
const encoded = await Promise.all(
files.slice(0, room).map((f) => compressImageToBase64(f)),
);
return { encoded };
} catch {
return { encoded: [], error: '图片处理失败' };
}
}

// 将图片 File 压缩为纯 base64(无 data: 前缀)。与 KYC/企业认证同一套:
// 缩放到最长边 2400px、JPEG 0.88,超 1.5MB 再降一档质量重试一次。
export async function compressImageToBase64(
Expand Down
20 changes: 13 additions & 7 deletions web/classic/src/components/layout/SiderBar.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ const routerMap = {
kyc: '/console/kyc',
enterprise: '/console/enterprise',
feedback: '/console/feedback',
myfeedback: '/console/myfeedback',
};

const SiderBar = ({ onNavigate = () => {} }) => {
Expand Down Expand Up @@ -161,10 +162,15 @@ const SiderBar = ({ onNavigate = () => {} }) => {
to: '/topup',
},
{
text: withUnreadBadge(t('个人设置'), userUnread),
text: t('个人设置'),
itemKey: 'personal',
to: '/personal',
},
{
text: withUnreadBadge(t('我的工单'), userUnread),
itemKey: 'myfeedback',
to: '/console/myfeedback',
},
];

// 根据配置过滤项目
Expand Down Expand Up @@ -220,6 +226,12 @@ const SiderBar = ({ onNavigate = () => {} }) => {
to: '/user',
className: isAdmin() ? '' : 'tableHiddle',
},
{
text: withUnreadBadge(t('工单管理'), adminUnread),
itemKey: 'feedback',
to: '/console/feedback',
className: isAdmin() ? '' : 'tableHiddle',
},
{
text: t('实名认证'),
itemKey: 'kyc',
Expand All @@ -232,12 +244,6 @@ const SiderBar = ({ onNavigate = () => {} }) => {
to: '/enterprise',
className: isAdmin() ? '' : 'tableHiddle',
},
{
text: withUnreadBadge(t('工单管理'), adminUnread),
itemKey: 'feedback',
to: '/console/feedback',
className: isAdmin() ? '' : 'tableHiddle',
},
{
text: t('系统设置'),
itemKey: 'setting',
Expand Down
4 changes: 0 additions & 4 deletions web/classic/src/components/settings/PersonalSetting.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,6 @@ import PreferencesSettings from './personal/cards/PreferencesSettings';
import CheckinCalendar from './personal/cards/CheckinCalendar';
import KYCSetting from './personal/cards/KYCSetting';
import EnterpriseSetting from './personal/cards/EnterpriseSetting';
import FeedbackConsult from './personal/cards/FeedbackConsult';
import EmailBindModal from './personal/modals/EmailBindModal';
import WeChatBindModal from './personal/modals/WeChatBindModal';
import AccountDeleteModal from './personal/modals/AccountDeleteModal';
Expand Down Expand Up @@ -615,9 +614,6 @@ const PersonalSetting = () => {

{/* 偏好设置(语言等) */}
<PreferencesSettings t={t} />

{/* 我的工单(建议及咨询) */}
<FeedbackConsult />
</div>

{/* 右侧:通知设置 + 实名认证 */}
Expand Down
Loading