mirror of
https://github.com/open-webui/open-webui.git
synced 2025-12-13 04:45:19 +00:00
avatar 菜单添加反馈渠道, 存储于数据库表 UserFeedback, 相同用户每四小时只能反馈一次
This commit is contained in:
parent
0389737154
commit
1d925ce46a
7 changed files with 402 additions and 0 deletions
|
|
@ -0,0 +1,42 @@
|
|||
"""Add user_feedback table for user suggestions
|
||||
|
||||
Revision ID: 1c2d3e4f5a6b
|
||||
Revises: f8c9d0e4a3b2
|
||||
Create Date: 2025-12-07 12:00:00.000000
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "1c2d3e4f5a6b"
|
||||
down_revision = "f8c9d0e4a3b2"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
# 新增 user_feedback 表:用于用户主动提交建议/反馈
|
||||
# 字段说明:
|
||||
# - id: 主键
|
||||
# - user_id: 提交用户
|
||||
# - content: 反馈正文
|
||||
# - contact: 联系方式(可选)
|
||||
# - status: 处理状态(pending/resolved)
|
||||
# - created_at/updated_at: 秒级时间戳
|
||||
op.create_table(
|
||||
"user_feedback",
|
||||
sa.Column("id", sa.Text(), primary_key=True),
|
||||
sa.Column("user_id", sa.Text(), nullable=False, index=True),
|
||||
sa.Column("content", sa.Text(), nullable=False),
|
||||
sa.Column("contact", sa.Text(), nullable=True),
|
||||
sa.Column("status", sa.Text(), nullable=False, server_default="pending"),
|
||||
sa.Column("created_at", sa.BigInteger(), nullable=False),
|
||||
sa.Column("updated_at", sa.BigInteger(), nullable=False),
|
||||
)
|
||||
# SQLite does not support server_default on existing rows; ensure default is set for new rows only.
|
||||
|
||||
|
||||
def downgrade():
|
||||
# 回滚时直接删除表
|
||||
op.drop_table("user_feedback")
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
"""Merge heads for user_feedback branch and legacy head
|
||||
|
||||
Revision ID: 2b3c4d5e6f7g
|
||||
Revises: 1c2d3e4f5a6b, b2c3d4e5f6a7
|
||||
Create Date: 2025-12-07 12:10:00.000000
|
||||
|
||||
此迁移用于合并分叉的两个 head(user_feedback 分支与 b2c3d4e5f6a7),不做实际数据变更。
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "2b3c4d5e6f7g"
|
||||
down_revision = ("1c2d3e4f5a6b", "b2c3d4e5f6a7")
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
# 纯合并迁移,无实际操作
|
||||
pass
|
||||
|
||||
|
||||
def downgrade():
|
||||
# 回滚时仅拆分分叉
|
||||
pass
|
||||
130
backend/open_webui/models/user_feedback.py
Normal file
130
backend/open_webui/models/user_feedback.py
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
import time
|
||||
import uuid
|
||||
from typing import Optional, List
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
from sqlalchemy import Column, Text, BigInteger
|
||||
|
||||
from open_webui.internal.db import Base, get_db
|
||||
from open_webui.env import SRC_LOG_LEVELS
|
||||
import logging
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
log.setLevel(SRC_LOG_LEVELS["MODELS"])
|
||||
|
||||
|
||||
class UserFeedback(Base):
|
||||
__tablename__ = "user_feedback"
|
||||
|
||||
# 用户主动反馈建议,带 4 小时限流。仅存最基本的信息,方便前端直接插入查看。
|
||||
id = Column(Text, primary_key=True)
|
||||
user_id = Column(Text, index=True, nullable=False)
|
||||
content = Column(Text, nullable=False) # 反馈正文
|
||||
contact = Column(Text, nullable=True) # 可选联系方式
|
||||
status = Column(Text, default="pending") # pending/resolved
|
||||
created_at = Column(BigInteger, nullable=False) # 秒级时间戳
|
||||
updated_at = Column(BigInteger, nullable=False)
|
||||
|
||||
|
||||
class UserFeedbackModel(BaseModel):
|
||||
id: str
|
||||
user_id: str
|
||||
content: str
|
||||
contact: Optional[str] = None
|
||||
status: str
|
||||
created_at: int
|
||||
updated_at: int
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class UserFeedbackForm(BaseModel):
|
||||
content: str
|
||||
contact: Optional[str] = None
|
||||
|
||||
|
||||
class UserFeedbacksTable:
|
||||
def create(
|
||||
self, user_id: str, content: str, contact: Optional[str] = None
|
||||
) -> Optional[UserFeedbackModel]:
|
||||
"""创建用户反馈,默认状态 pending。"""
|
||||
now_ts = int(time.time())
|
||||
record = UserFeedbackModel(
|
||||
id=str(uuid.uuid4()),
|
||||
user_id=user_id,
|
||||
content=content,
|
||||
contact=contact,
|
||||
status="pending",
|
||||
created_at=now_ts,
|
||||
updated_at=now_ts,
|
||||
)
|
||||
try:
|
||||
with get_db() as db:
|
||||
db_item = UserFeedback(**record.model_dump())
|
||||
db.add(db_item)
|
||||
db.commit()
|
||||
db.refresh(db_item)
|
||||
return UserFeedbackModel.model_validate(db_item)
|
||||
except Exception as e:
|
||||
log.exception(f"Error creating user feedback: {e}")
|
||||
return None
|
||||
|
||||
def get_recent_within(self, user_id: str, seconds: int) -> Optional[UserFeedbackModel]:
|
||||
"""查询指定时间窗口内最新一条反馈,用于冷却期检查。"""
|
||||
try:
|
||||
with get_db() as db:
|
||||
cutoff = int(time.time()) - seconds
|
||||
item = (
|
||||
db.query(UserFeedback)
|
||||
.filter(UserFeedback.user_id == user_id, UserFeedback.created_at >= cutoff)
|
||||
.order_by(UserFeedback.created_at.desc())
|
||||
.first()
|
||||
)
|
||||
if not item:
|
||||
return None
|
||||
return UserFeedbackModel.model_validate(item)
|
||||
except Exception as e:
|
||||
log.exception(f"Error reading recent user feedback: {e}")
|
||||
return None
|
||||
|
||||
def list_by_user(self, user_id: str) -> List[UserFeedbackModel]:
|
||||
"""按用户列出反馈,按时间倒序。"""
|
||||
with get_db() as db:
|
||||
items = (
|
||||
db.query(UserFeedback)
|
||||
.filter(UserFeedback.user_id == user_id)
|
||||
.order_by(UserFeedback.created_at.desc())
|
||||
.all()
|
||||
)
|
||||
return [UserFeedbackModel.model_validate(x) for x in items]
|
||||
|
||||
def list_all(self) -> List[UserFeedbackModel]:
|
||||
"""管理员查看全部反馈。"""
|
||||
with get_db() as db:
|
||||
items = db.query(UserFeedback).order_by(UserFeedback.created_at.desc()).all()
|
||||
return [UserFeedbackModel.model_validate(x) for x in items]
|
||||
|
||||
def update_status(self, id: str, status: str) -> Optional[UserFeedbackModel]:
|
||||
"""更新状态(例如管理员处理后标记 resolved)。"""
|
||||
with get_db() as db:
|
||||
item = db.query(UserFeedback).filter_by(id=id).first()
|
||||
if not item:
|
||||
return None
|
||||
item.status = status
|
||||
item.updated_at = int(time.time())
|
||||
db.commit()
|
||||
db.refresh(item)
|
||||
return UserFeedbackModel.model_validate(item)
|
||||
|
||||
def delete_by_id(self, id: str) -> bool:
|
||||
"""删除单条反馈。"""
|
||||
with get_db() as db:
|
||||
item = db.query(UserFeedback).filter_by(id=id).first()
|
||||
if not item:
|
||||
return False
|
||||
db.delete(item)
|
||||
db.commit()
|
||||
return True
|
||||
|
||||
|
||||
UserFeedbacks = UserFeedbacksTable()
|
||||
|
|
@ -9,6 +9,11 @@ from open_webui.models.feedbacks import (
|
|||
FeedbackForm,
|
||||
Feedbacks,
|
||||
)
|
||||
from open_webui.models.user_feedback import (
|
||||
UserFeedbackModel,
|
||||
UserFeedbackForm,
|
||||
UserFeedbacks,
|
||||
)
|
||||
|
||||
from open_webui.constants import ERROR_MESSAGES
|
||||
from open_webui.utils.auth import get_admin_user, get_verified_user
|
||||
|
|
@ -71,6 +76,11 @@ class FeedbackUserResponse(FeedbackResponse):
|
|||
user: Optional[UserResponse] = None
|
||||
|
||||
|
||||
class UserSuggestionForm(BaseModel):
|
||||
content: str
|
||||
contact: Optional[str] = None
|
||||
|
||||
|
||||
@router.get("/feedbacks/all", response_model=list[FeedbackUserResponse])
|
||||
async def get_all_feedbacks(user=Depends(get_admin_user)):
|
||||
feedbacks = Feedbacks.get_all_feedbacks()
|
||||
|
|
@ -127,6 +137,56 @@ async def create_feedback(
|
|||
return feedback
|
||||
|
||||
|
||||
@router.post("/feedback/suggestion", response_model=UserFeedbackModel)
|
||||
async def create_suggestion_feedback(
|
||||
form_data: UserSuggestionForm, user=Depends(get_verified_user)
|
||||
):
|
||||
"""用户主动反馈入口:带 4 小时冷却期,命中则返回 429。"""
|
||||
content = (form_data.content or "").strip()
|
||||
if not content:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="反馈内容不能为空",
|
||||
)
|
||||
|
||||
# 4 小时限流
|
||||
recent = UserFeedbacks.get_recent_within(user.id, seconds=4 * 60 * 60)
|
||||
if recent:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||
detail="您最近反馈过,请再等等一天",
|
||||
)
|
||||
|
||||
feedback = UserFeedbacks.create(
|
||||
user_id=user.id,
|
||||
content=content,
|
||||
contact=(form_data.contact or "").strip() or None,
|
||||
)
|
||||
if not feedback:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=ERROR_MESSAGES.DEFAULT(),
|
||||
)
|
||||
return feedback
|
||||
|
||||
|
||||
@router.get("/feedbacks/suggestion", response_model=list[UserFeedbackModel])
|
||||
async def list_user_suggestions(user=Depends(get_admin_user)):
|
||||
"""管理员查看所有用户反馈。"""
|
||||
return UserFeedbacks.list_all()
|
||||
|
||||
|
||||
@router.delete("/feedback/suggestion/{id}")
|
||||
async def delete_user_suggestion(id: str, user=Depends(get_admin_user)):
|
||||
"""管理员删除单条用户反馈。"""
|
||||
success = UserFeedbacks.delete_by_id(id)
|
||||
if not success:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
@router.get("/feedback/{id}", response_model=FeedbackModel)
|
||||
async def get_feedback_by_id(id: str, user=Depends(get_verified_user)):
|
||||
if user.role == "admin":
|
||||
|
|
|
|||
|
|
@ -155,6 +155,35 @@ export const createNewFeedback = async (token: string, feedback: object) => {
|
|||
return res;
|
||||
};
|
||||
|
||||
export const createUserSuggestionFeedback = async (token: string, body: { content: string; contact?: string }) => {
|
||||
let error = null;
|
||||
|
||||
const res = await fetch(`${WEBUI_API_BASE_URL}/evaluations/feedback/suggestion`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
authorization: `Bearer ${token}`
|
||||
},
|
||||
body: JSON.stringify(body)
|
||||
})
|
||||
.then(async (res) => {
|
||||
if (!res.ok) throw await res.json();
|
||||
return res.json();
|
||||
})
|
||||
.catch((err) => {
|
||||
error = err.detail ?? err;
|
||||
console.error(err);
|
||||
return null;
|
||||
});
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
return res;
|
||||
};
|
||||
|
||||
export const getFeedbackById = async (token: string, feedbackId: string) => {
|
||||
let error = null;
|
||||
|
||||
|
|
|
|||
93
src/lib/components/common/UserSuggestionModal.svelte
Normal file
93
src/lib/components/common/UserSuggestionModal.svelte
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
<script lang="ts">
|
||||
import { getContext } from 'svelte';
|
||||
import { toast } from 'svelte-sonner';
|
||||
|
||||
import Modal from '$lib/components/common/Modal.svelte';
|
||||
import { createUserSuggestionFeedback } from '$lib/apis/evaluations';
|
||||
|
||||
const i18n = getContext('i18n');
|
||||
|
||||
export let show = false;
|
||||
export let onClose: () => void = () => {};
|
||||
|
||||
let content = '';
|
||||
let contact = '';
|
||||
let submitting = false;
|
||||
|
||||
const reset = () => {
|
||||
content = '';
|
||||
contact = '';
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
reset();
|
||||
onClose();
|
||||
};
|
||||
|
||||
const submit = async () => {
|
||||
if (submitting) return;
|
||||
const trimmed = content.trim();
|
||||
if (!trimmed) {
|
||||
toast.error('请填写反馈内容');
|
||||
return;
|
||||
}
|
||||
submitting = true;
|
||||
try {
|
||||
await createUserSuggestionFeedback(localStorage.token, {
|
||||
content: trimmed,
|
||||
contact: contact.trim() || undefined
|
||||
});
|
||||
toast.success('反馈已提交,感谢您的建议');
|
||||
handleClose();
|
||||
} catch (err) {
|
||||
const msg = typeof err === 'string' ? err : err?.detail ?? '提交失败,请稍后重试';
|
||||
toast.error(msg);
|
||||
} finally {
|
||||
submitting = false;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<Modal bind:show className="bg-white dark:bg-gray-900 rounded-2xl" size="sm">
|
||||
<div class="p-4 sm:p-6 space-y-4">
|
||||
<div class="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{$i18n.t('反馈') ?? '反馈'}
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<label class="text-sm text-gray-600 dark:text-gray-300">{$i18n.t('反馈内容') ?? '反馈内容'}</label>
|
||||
<textarea
|
||||
class="w-full rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-850 p-3 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
rows="5"
|
||||
placeholder={$i18n.t('请描述您遇到的问题或建议') ?? '请描述您遇到的问题或建议'}
|
||||
bind:value={content}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<label class="text-sm text-gray-600 dark:text-gray-300">{$i18n.t('联系方式(可选)') ?? '联系方式(可选)'}</label>
|
||||
<input
|
||||
class="w-full rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-850 p-3 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
placeholder={$i18n.t('邮箱/手机号/微信等') ?? '邮箱/手机号/微信等'}
|
||||
bind:value={contact}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-3 pt-2">
|
||||
<button
|
||||
class="px-4 py-2 rounded-xl text-sm text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800"
|
||||
on:click={handleClose}
|
||||
disabled={submitting}
|
||||
>
|
||||
{$i18n.t('取消') ?? '取消'}
|
||||
</button>
|
||||
<button
|
||||
class="px-4 py-2 rounded-xl text-sm text-white bg-blue-600 hover:bg-blue-700 disabled:opacity-60"
|
||||
on:click={submit}
|
||||
disabled={submitting}
|
||||
>
|
||||
{submitting ? $i18n.t('提交中...') ?? '提交中...' : $i18n.t('提交') ?? '提交'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
|
@ -23,6 +23,7 @@
|
|||
import UserGroup from '$lib/components/icons/UserGroup.svelte';
|
||||
import SignOut from '$lib/components/icons/SignOut.svelte';
|
||||
import BalanceDisplay from '$lib/components/billing/BalanceDisplay.svelte';
|
||||
import UserSuggestionModal from '$lib/components/common/UserSuggestionModal.svelte';
|
||||
|
||||
const i18n = getContext('i18n');
|
||||
|
||||
|
|
@ -34,6 +35,7 @@
|
|||
const dispatch = createEventDispatcher();
|
||||
|
||||
let usage = null;
|
||||
let showSuggestionModal = false;
|
||||
const getUsageInfo = async () => {
|
||||
const res = await getUsage(localStorage.token).catch((error) => {
|
||||
console.error('Error fetching usage info:', error);
|
||||
|
|
@ -288,6 +290,19 @@
|
|||
</DropdownMenu.Item>
|
||||
{/if}
|
||||
|
||||
<DropdownMenu.Item
|
||||
class="flex rounded-xl py-1.5 px-3 w-full hover:bg-gray-50 dark:hover:bg-gray-800 transition cursor-pointer"
|
||||
on:click={() => {
|
||||
show = false;
|
||||
showSuggestionModal = true;
|
||||
}}
|
||||
>
|
||||
<div class=" self-center mr-3">
|
||||
<span class="inline-flex h-5 w-5 items-center justify-center rounded-full bg-blue-500 text-[11px] font-semibold text-white">反</span>
|
||||
</div>
|
||||
<div class=" self-center truncate">{$i18n.t('反馈')}</div>
|
||||
</DropdownMenu.Item>
|
||||
|
||||
<hr class=" border-gray-50 dark:border-gray-800 my-1 p-0" />
|
||||
|
||||
<DropdownMenu.Item
|
||||
|
|
@ -350,3 +365,10 @@
|
|||
</DropdownMenu.Content>
|
||||
</slot>
|
||||
</DropdownMenu.Root>
|
||||
|
||||
<UserSuggestionModal
|
||||
bind:show={showSuggestionModal}
|
||||
onClose={() => {
|
||||
showSuggestionModal = false;
|
||||
}}
|
||||
/>
|
||||
|
|
|
|||
Loading…
Reference in a new issue