mirror of
https://github.com/open-webui/open-webui.git
synced 2025-12-15 13:55: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,
|
FeedbackForm,
|
||||||
Feedbacks,
|
Feedbacks,
|
||||||
)
|
)
|
||||||
|
from open_webui.models.user_feedback import (
|
||||||
|
UserFeedbackModel,
|
||||||
|
UserFeedbackForm,
|
||||||
|
UserFeedbacks,
|
||||||
|
)
|
||||||
|
|
||||||
from open_webui.constants import ERROR_MESSAGES
|
from open_webui.constants import ERROR_MESSAGES
|
||||||
from open_webui.utils.auth import get_admin_user, get_verified_user
|
from open_webui.utils.auth import get_admin_user, get_verified_user
|
||||||
|
|
@ -71,6 +76,11 @@ class FeedbackUserResponse(FeedbackResponse):
|
||||||
user: Optional[UserResponse] = None
|
user: Optional[UserResponse] = None
|
||||||
|
|
||||||
|
|
||||||
|
class UserSuggestionForm(BaseModel):
|
||||||
|
content: str
|
||||||
|
contact: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
@router.get("/feedbacks/all", response_model=list[FeedbackUserResponse])
|
@router.get("/feedbacks/all", response_model=list[FeedbackUserResponse])
|
||||||
async def get_all_feedbacks(user=Depends(get_admin_user)):
|
async def get_all_feedbacks(user=Depends(get_admin_user)):
|
||||||
feedbacks = Feedbacks.get_all_feedbacks()
|
feedbacks = Feedbacks.get_all_feedbacks()
|
||||||
|
|
@ -127,6 +137,56 @@ async def create_feedback(
|
||||||
return 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)
|
@router.get("/feedback/{id}", response_model=FeedbackModel)
|
||||||
async def get_feedback_by_id(id: str, user=Depends(get_verified_user)):
|
async def get_feedback_by_id(id: str, user=Depends(get_verified_user)):
|
||||||
if user.role == "admin":
|
if user.role == "admin":
|
||||||
|
|
|
||||||
|
|
@ -155,6 +155,35 @@ export const createNewFeedback = async (token: string, feedback: object) => {
|
||||||
return res;
|
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) => {
|
export const getFeedbackById = async (token: string, feedbackId: string) => {
|
||||||
let error = null;
|
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 UserGroup from '$lib/components/icons/UserGroup.svelte';
|
||||||
import SignOut from '$lib/components/icons/SignOut.svelte';
|
import SignOut from '$lib/components/icons/SignOut.svelte';
|
||||||
import BalanceDisplay from '$lib/components/billing/BalanceDisplay.svelte';
|
import BalanceDisplay from '$lib/components/billing/BalanceDisplay.svelte';
|
||||||
|
import UserSuggestionModal from '$lib/components/common/UserSuggestionModal.svelte';
|
||||||
|
|
||||||
const i18n = getContext('i18n');
|
const i18n = getContext('i18n');
|
||||||
|
|
||||||
|
|
@ -34,6 +35,7 @@
|
||||||
const dispatch = createEventDispatcher();
|
const dispatch = createEventDispatcher();
|
||||||
|
|
||||||
let usage = null;
|
let usage = null;
|
||||||
|
let showSuggestionModal = false;
|
||||||
const getUsageInfo = async () => {
|
const getUsageInfo = async () => {
|
||||||
const res = await getUsage(localStorage.token).catch((error) => {
|
const res = await getUsage(localStorage.token).catch((error) => {
|
||||||
console.error('Error fetching usage info:', error);
|
console.error('Error fetching usage info:', error);
|
||||||
|
|
@ -288,6 +290,19 @@
|
||||||
</DropdownMenu.Item>
|
</DropdownMenu.Item>
|
||||||
{/if}
|
{/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" />
|
<hr class=" border-gray-50 dark:border-gray-800 my-1 p-0" />
|
||||||
|
|
||||||
<DropdownMenu.Item
|
<DropdownMenu.Item
|
||||||
|
|
@ -350,3 +365,10 @@
|
||||||
</DropdownMenu.Content>
|
</DropdownMenu.Content>
|
||||||
</slot>
|
</slot>
|
||||||
</DropdownMenu.Root>
|
</DropdownMenu.Root>
|
||||||
|
|
||||||
|
<UserSuggestionModal
|
||||||
|
bind:show={showSuggestionModal}
|
||||||
|
onClose={() => {
|
||||||
|
showSuggestionModal = false;
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue