feat: add ticket list/edit pages (#4651)

This commit is contained in:
Yang Luo
2025-12-12 23:16:47 +08:00
parent 36cadded1c
commit 387a22d5f8
9 changed files with 1120 additions and 1 deletions

271
controllers/ticket.go Normal file
View File

@@ -0,0 +1,271 @@
// Copyright 2024 The Casdoor Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package controllers
import (
"encoding/json"
"github.com/beego/beego/utils/pagination"
"github.com/casdoor/casdoor/object"
"github.com/casdoor/casdoor/util"
)
// GetTickets
// @Title GetTickets
// @Tag Ticket API
// @Description get tickets
// @Param owner query string true "The owner of tickets"
// @Success 200 {array} object.Ticket The Response object
// @router /get-tickets [get]
func (c *ApiController) GetTickets() {
owner := c.Input().Get("owner")
limit := c.Input().Get("pageSize")
page := c.Input().Get("p")
field := c.Input().Get("field")
value := c.Input().Get("value")
sortField := c.Input().Get("sortField")
sortOrder := c.Input().Get("sortOrder")
user := c.getCurrentUser()
isAdmin := c.IsAdmin()
var tickets []*object.Ticket
var err error
if limit == "" || page == "" {
if isAdmin {
tickets, err = object.GetTickets(owner)
} else {
tickets, err = object.GetUserTickets(owner, user.GetId())
}
if err != nil {
c.ResponseError(err.Error())
return
}
c.ResponseOk(tickets)
} else {
limit := util.ParseInt(limit)
var count int64
if isAdmin {
count, err = object.GetTicketCount(owner, field, value)
} else {
// For non-admin users, only show their own tickets
tickets, err = object.GetUserTickets(owner, user.GetId())
if err != nil {
c.ResponseError(err.Error())
return
}
count = int64(len(tickets))
}
if err != nil {
c.ResponseError(err.Error())
return
}
paginator := pagination.SetPaginator(c.Ctx, limit, count)
if isAdmin {
tickets, err = object.GetPaginationTickets(owner, paginator.Offset(), limit, field, value, sortField, sortOrder)
}
if err != nil {
c.ResponseError(err.Error())
return
}
c.ResponseOk(tickets, paginator.Nums())
}
}
// GetTicket
// @Title GetTicket
// @Tag Ticket API
// @Description get ticket
// @Param id query string true "The id ( owner/name ) of the ticket"
// @Success 200 {object} object.Ticket The Response object
// @router /get-ticket [get]
func (c *ApiController) GetTicket() {
id := c.Input().Get("id")
ticket, err := object.GetTicket(id)
if err != nil {
c.ResponseError(err.Error())
return
}
// Check permission: user can only view their own tickets unless they are admin
user := c.getCurrentUser()
isAdmin := c.IsAdmin()
if ticket != nil && !isAdmin && ticket.User != user.GetId() {
c.ResponseError(c.T("auth:Unauthorized operation"))
return
}
c.ResponseOk(ticket)
}
// UpdateTicket
// @Title UpdateTicket
// @Tag Ticket API
// @Description update ticket
// @Param id query string true "The id ( owner/name ) of the ticket"
// @Param body body object.Ticket true "The details of the ticket"
// @Success 200 {object} controllers.Response The Response object
// @router /update-ticket [post]
func (c *ApiController) UpdateTicket() {
id := c.Input().Get("id")
var ticket object.Ticket
err := json.Unmarshal(c.Ctx.Input.RequestBody, &ticket)
if err != nil {
c.ResponseError(err.Error())
return
}
// Check permission
user := c.getCurrentUser()
isAdmin := c.IsAdmin()
existingTicket, err := object.GetTicket(id)
if err != nil {
c.ResponseError(err.Error())
return
}
if existingTicket == nil {
c.ResponseError(c.T("ticket:Ticket not found"))
return
}
// Normal users can only close their own tickets
if !isAdmin {
if existingTicket.User != user.GetId() {
c.ResponseError(c.T("auth:Unauthorized operation"))
return
}
// Normal users can only change state to "Closed"
if ticket.State != "Closed" && ticket.State != existingTicket.State {
c.ResponseError(c.T("auth:Unauthorized operation"))
return
}
// Preserve original fields that users shouldn't modify
ticket.Owner = existingTicket.Owner
ticket.Name = existingTicket.Name
ticket.User = existingTicket.User
ticket.CreatedTime = existingTicket.CreatedTime
}
c.Data["json"] = wrapActionResponse(object.UpdateTicket(id, &ticket))
c.ServeJSON()
}
// AddTicket
// @Title AddTicket
// @Tag Ticket API
// @Description add ticket
// @Param body body object.Ticket true "The details of the ticket"
// @Success 200 {object} controllers.Response The Response object
// @router /add-ticket [post]
func (c *ApiController) AddTicket() {
var ticket object.Ticket
err := json.Unmarshal(c.Ctx.Input.RequestBody, &ticket)
if err != nil {
c.ResponseError(err.Error())
return
}
// Set the user field to the current user
user := c.getCurrentUser()
ticket.User = user.GetId()
c.Data["json"] = wrapActionResponse(object.AddTicket(&ticket))
c.ServeJSON()
}
// DeleteTicket
// @Title DeleteTicket
// @Tag Ticket API
// @Description delete ticket
// @Param body body object.Ticket true "The details of the ticket"
// @Success 200 {object} controllers.Response The Response object
// @router /delete-ticket [post]
func (c *ApiController) DeleteTicket() {
var ticket object.Ticket
err := json.Unmarshal(c.Ctx.Input.RequestBody, &ticket)
if err != nil {
c.ResponseError(err.Error())
return
}
// Only admins can delete tickets
if !c.IsAdmin() {
c.ResponseError(c.T("auth:Unauthorized operation"))
return
}
c.Data["json"] = wrapActionResponse(object.DeleteTicket(&ticket))
c.ServeJSON()
}
// AddTicketMessage
// @Title AddTicketMessage
// @Tag Ticket API
// @Description add a message to a ticket
// @Param id query string true "The id ( owner/name ) of the ticket"
// @Param body body object.TicketMessage true "The message to add"
// @Success 200 {object} controllers.Response The Response object
// @router /add-ticket-message [post]
func (c *ApiController) AddTicketMessage() {
id := c.Input().Get("id")
var message object.TicketMessage
err := json.Unmarshal(c.Ctx.Input.RequestBody, &message)
if err != nil {
c.ResponseError(err.Error())
return
}
// Check permission
user := c.getCurrentUser()
isAdmin := c.IsAdmin()
ticket, err := object.GetTicket(id)
if err != nil {
c.ResponseError(err.Error())
return
}
if ticket == nil {
c.ResponseError(c.T("ticket:Ticket not found"))
return
}
// Users can only add messages to their own tickets, admins can add to any ticket
if !isAdmin && ticket.User != user.GetId() {
c.ResponseError(c.T("auth:Unauthorized operation"))
return
}
// Set the author and admin flag
message.Author = user.GetId()
message.IsAdmin = isAdmin
c.Data["json"] = wrapActionResponse(object.AddTicketMessage(id, &message))
c.ServeJSON()
}

View File

@@ -448,4 +448,9 @@ func (a *Ormer) createTable() {
if err != nil {
panic(err)
}
err = a.Engine.Sync2(new(Ticket))
if err != nil {
panic(err)
}
}

162
object/ticket.go Normal file
View File

@@ -0,0 +1,162 @@
// Copyright 2024 The Casdoor Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package object
import (
"fmt"
"github.com/casdoor/casdoor/util"
"github.com/xorm-io/core"
)
type TicketMessage struct {
Author string `json:"author"`
Text string `json:"text"`
Timestamp string `json:"timestamp"`
IsAdmin bool `json:"isAdmin"`
}
type Ticket struct {
Owner string `xorm:"varchar(100) notnull pk" json:"owner"`
Name string `xorm:"varchar(100) notnull pk" json:"name"`
CreatedTime string `xorm:"varchar(100)" json:"createdTime"`
UpdatedTime string `xorm:"varchar(100)" json:"updatedTime"`
DisplayName string `xorm:"varchar(100)" json:"displayName"`
User string `xorm:"varchar(100) index" json:"user"`
Title string `xorm:"varchar(200)" json:"title"`
Content string `xorm:"mediumtext" json:"content"`
State string `xorm:"varchar(50)" json:"state"`
Messages []*TicketMessage `xorm:"mediumtext json" json:"messages"`
}
func GetTicketCount(owner, field, value string) (int64, error) {
session := GetSession(owner, -1, -1, field, value, "", "")
return session.Count(&Ticket{})
}
func GetTickets(owner string) ([]*Ticket, error) {
tickets := []*Ticket{}
err := ormer.Engine.Desc("created_time").Find(&tickets, &Ticket{Owner: owner})
if err != nil {
return tickets, err
}
return tickets, nil
}
func GetPaginationTickets(owner string, offset, limit int, field, value, sortField, sortOrder string) ([]*Ticket, error) {
tickets := []*Ticket{}
session := GetSession(owner, offset, limit, field, value, sortField, sortOrder)
err := session.Find(&tickets)
if err != nil {
return tickets, err
}
return tickets, nil
}
func GetUserTickets(owner, user string) ([]*Ticket, error) {
tickets := []*Ticket{}
err := ormer.Engine.Desc("created_time").Find(&tickets, &Ticket{Owner: owner, User: user})
if err != nil {
return tickets, err
}
return tickets, nil
}
func getTicket(owner string, name string) (*Ticket, error) {
if owner == "" || name == "" {
return nil, nil
}
ticket := Ticket{Owner: owner, Name: name}
existed, err := ormer.Engine.Get(&ticket)
if err != nil {
return &ticket, err
}
if existed {
return &ticket, nil
}
return nil, nil
}
func GetTicket(id string) (*Ticket, error) {
owner, name, err := util.GetOwnerAndNameFromIdWithError(id)
if err != nil {
return nil, err
}
return getTicket(owner, name)
}
func UpdateTicket(id string, ticket *Ticket) (bool, error) {
owner, name, err := util.GetOwnerAndNameFromIdWithError(id)
if err != nil {
return false, err
}
if t, err := getTicket(owner, name); err != nil {
return false, err
} else if t == nil {
return false, nil
}
affected, err := ormer.Engine.ID(core.PK{owner, name}).AllCols().Update(ticket)
if err != nil {
return false, err
}
return affected != 0, nil
}
func AddTicket(ticket *Ticket) (bool, error) {
affected, err := ormer.Engine.Insert(ticket)
if err != nil {
return false, err
}
return affected != 0, nil
}
func DeleteTicket(ticket *Ticket) (bool, error) {
affected, err := ormer.Engine.ID(core.PK{ticket.Owner, ticket.Name}).Delete(&Ticket{})
if err != nil {
return false, err
}
return affected != 0, nil
}
func (ticket *Ticket) GetId() string {
return fmt.Sprintf("%s/%s", ticket.Owner, ticket.Name)
}
func AddTicketMessage(id string, message *TicketMessage) (bool, error) {
ticket, err := GetTicket(id)
if err != nil {
return false, err
}
if ticket == nil {
return false, fmt.Errorf("ticket not found: %s", id)
}
if ticket.Messages == nil {
ticket.Messages = []*TicketMessage{}
}
ticket.Messages = append(ticket.Messages, message)
return UpdateTicket(id, ticket)
}

View File

@@ -271,6 +271,13 @@ func initAPI() {
beego.Router("/api/add-webhook", &controllers.ApiController{}, "POST:AddWebhook")
beego.Router("/api/delete-webhook", &controllers.ApiController{}, "POST:DeleteWebhook")
beego.Router("/api/get-tickets", &controllers.ApiController{}, "GET:GetTickets")
beego.Router("/api/get-ticket", &controllers.ApiController{}, "GET:GetTicket")
beego.Router("/api/update-ticket", &controllers.ApiController{}, "POST:UpdateTicket")
beego.Router("/api/add-ticket", &controllers.ApiController{}, "POST:AddTicket")
beego.Router("/api/delete-ticket", &controllers.ApiController{}, "POST:DeleteTicket")
beego.Router("/api/add-ticket-message", &controllers.ApiController{}, "POST:AddTicketMessage")
beego.Router("/api/set-password", &controllers.ApiController{}, "POST:SetPassword")
beego.Router("/api/check-user-password", &controllers.ApiController{}, "POST:CheckUserPassword")
beego.Router("/api/get-email-and-phone", &controllers.ApiController{}, "GET:GetEmailAndPhone")

View File

@@ -99,6 +99,8 @@ import {clearWeb3AuthToken} from "./auth/Web3Auth";
import TransactionListPage from "./TransactionListPage";
import TransactionEditPage from "./TransactionEditPage";
import VerificationListPage from "./VerificationListPage";
import TicketListPage from "./TicketListPage";
import TicketEditPage from "./TicketEditPage";
function ManagementPage(props) {
const [menuVisible, setMenuVisible] = useState(false);
@@ -343,12 +345,14 @@ function ManagementPage(props) {
Setting.getItem(<Link to="/forms">{i18next.t("general:Forms")}</Link>, "/forms"),
Setting.getItem(<Link to="/syncers">{i18next.t("general:Syncers")}</Link>, "/syncers"),
Setting.getItem(<Link to="/webhooks">{i18next.t("general:Webhooks")}</Link>, "/webhooks"),
Setting.getItem(<Link to="/tickets">{i18next.t("general:Tickets")}</Link>, "/tickets"),
Setting.getItem(<a target="_blank" rel="noreferrer" href={Setting.isLocalhost() ? `${Setting.ServerUrl}/swagger` : "/swagger"}>{i18next.t("general:Swagger")}</a>, "/swagger")]));
} else {
res.push(Setting.getItem(<Link style={{color: textColor}} to="/syncers">{i18next.t("general:Admin")}</Link>, "/admin", <SettingTwoTone twoToneColor={twoToneColor} />, [
Setting.getItem(<Link to="/forms">{i18next.t("general:Forms")}</Link>, "/forms"),
Setting.getItem(<Link to="/syncers">{i18next.t("general:Syncers")}</Link>, "/syncers"),
Setting.getItem(<Link to="/webhooks">{i18next.t("general:Webhooks")}</Link>, "/webhooks")]));
Setting.getItem(<Link to="/webhooks">{i18next.t("general:Webhooks")}</Link>, "/webhooks"),
Setting.getItem(<Link to="/tickets">{i18next.t("general:Tickets")}</Link>, "/tickets")]));
}
if (navItemsIsAll()) {
@@ -483,6 +487,8 @@ function ManagementPage(props) {
<Route exact path="/transactions/:organizationName/:transactionName" render={(props) => renderLoginIfNotLoggedIn(<TransactionEditPage account={account} {...props} />)} />
<Route exact path="/webhooks" render={(props) => renderLoginIfNotLoggedIn(<WebhookListPage account={account} {...props} />)} />
<Route exact path="/webhooks/:webhookName" render={(props) => renderLoginIfNotLoggedIn(<WebhookEditPage account={account} {...props} />)} />
<Route exact path="/tickets" render={(props) => renderLoginIfNotLoggedIn(<TicketListPage account={account} {...props} />)} />
<Route exact path="/tickets/:organizationName/:ticketName" render={(props) => renderLoginIfNotLoggedIn(<TicketEditPage account={account} {...props} />)} />
<Route exact path="/ldap/:organizationName/:ldapId" render={(props) => renderLoginIfNotLoggedIn(<LdapEditPage account={account} {...props} />)} />
<Route exact path="/ldap/sync/:organizationName/:ldapId" render={(props) => renderLoginIfNotLoggedIn(<LdapSyncPage account={account} {...props} />)} />
<Route exact path="/mfa/setup" render={(props) => renderLoginIfNotLoggedIn(<MfaSetupPage account={account} onfinish={onfinish} {...props} />)} />

318
web/src/TicketEditPage.js Normal file
View File

@@ -0,0 +1,318 @@
// Copyright 2024 The Casdoor Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import React from "react";
import {Avatar, Button, Card, Col, Divider, Input, List, Row, Select, Space, Tag} from "antd";
import {SendOutlined, UserOutlined} from "@ant-design/icons";
import * as TicketBackend from "./backend/TicketBackend";
import * as Setting from "./Setting";
import i18next from "i18next";
import moment from "moment";
const {Option} = Select;
const {TextArea} = Input;
class TicketEditPage extends React.Component {
constructor(props) {
super(props);
this.state = {
classes: props,
organizationName: props.organizationName !== undefined ? props.organizationName : props.match.params.organizationName,
ticketName: props.match.params.ticketName,
ticket: null,
mode: props.location.mode !== undefined ? props.location.mode : "edit",
messageText: "",
sending: false,
};
}
UNSAFE_componentWillMount() {
this.getTicket();
}
getTicket() {
TicketBackend.getTicket(this.state.organizationName, this.state.ticketName)
.then((res) => {
if (res.data === null) {
this.props.history.push("/404");
return;
}
if (res.data.messages === null) {
res.data.messages = [];
}
this.setState({
ticket: res.data,
});
});
}
parseTicketField(key, value) {
if ([""].includes(key)) {
value = Setting.myParseInt(value);
}
return value;
}
updateTicketField(key, value) {
value = this.parseTicketField(key, value);
const ticket = this.state.ticket;
ticket[key] = value;
this.setState({
ticket: ticket,
});
}
submitTicketEdit(willExist) {
const ticket = Setting.deepCopy(this.state.ticket);
TicketBackend.updateTicket(this.state.organizationName, this.state.ticketName, ticket)
.then((res) => {
if (res.status === "ok") {
Setting.showMessage("success", i18next.t("general:Successfully saved"));
this.setState({
ticketName: this.state.ticket.name,
});
if (willExist) {
this.props.history.push("/tickets");
} else {
this.props.history.push(`/tickets/${this.state.ticket.owner}/${this.state.ticket.name}`);
}
} else {
Setting.showMessage("error", `${i18next.t("general:Failed to save")}: ${res.msg}`);
this.updateTicketField("name", this.state.ticketName);
}
})
.catch(error => {
Setting.showMessage("error", `${i18next.t("general:Failed to connect to server")}: ${error}`);
});
}
sendMessage() {
if (!this.state.messageText.trim()) {
Setting.showMessage("error", i18next.t("ticket:Please enter a message"));
return;
}
this.setState({sending: true});
const message = {
author: this.props.account.name,
text: this.state.messageText,
timestamp: moment().format(),
isAdmin: Setting.isAdminUser(this.props.account),
};
TicketBackend.addTicketMessage(this.state.organizationName, this.state.ticketName, message)
.then((res) => {
if (res.status === "ok") {
Setting.showMessage("success", i18next.t("general:Successfully sent"));
this.setState({
messageText: "",
sending: false,
});
this.getTicket();
} else {
Setting.showMessage("error", `${i18next.t("general:Failed to send")}: ${res.msg}`);
this.setState({sending: false});
}
})
.catch(error => {
Setting.showMessage("error", `${i18next.t("general:Failed to connect to server")}: ${error}`);
this.setState({sending: false});
});
}
renderTicket() {
const isAdmin = Setting.isAdminUser(this.props.account);
const isOwner = this.props.account.name === this.state.ticket?.user;
return (
<Card size="small" title={
<div>
{this.state.mode === "add" ? i18next.t("ticket:New Ticket") : i18next.t("ticket:Edit Ticket")}&nbsp;&nbsp;&nbsp;&nbsp;
<Button onClick={() => this.submitTicketEdit(false)}>{i18next.t("general:Save")}</Button>
<Button style={{marginLeft: "20px"}} type="primary" onClick={() => this.submitTicketEdit(true)}>{i18next.t("general:Save & Exit")}</Button>
</div>
} style={{marginLeft: "5px"}} type="inner">
<Row style={{marginTop: "10px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}>
{i18next.t("general:Organization")}:
</Col>
<Col span={22} >
<Input value={this.state.ticket.owner} disabled={true} />
</Col>
</Row>
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}>
{i18next.t("general:Name")}:
</Col>
<Col span={22} >
<Input value={this.state.ticket.name} disabled={!isAdmin} onChange={e => {
this.updateTicketField("name", e.target.value);
}} />
</Col>
</Row>
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}>
{i18next.t("general:Display name")}:
</Col>
<Col span={22} >
<Input value={this.state.ticket.displayName} onChange={e => {
this.updateTicketField("displayName", e.target.value);
}} />
</Col>
</Row>
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}>
{i18next.t("general:Title")}:
</Col>
<Col span={22} >
<Input value={this.state.ticket.title} disabled={!isAdmin && !isOwner} onChange={e => {
this.updateTicketField("title", e.target.value);
}} />
</Col>
</Row>
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}>
{i18next.t("general:Content")}:
</Col>
<Col span={22} >
<TextArea autoSize={{minRows: 3, maxRows: 10}} value={this.state.ticket.content} disabled={!isAdmin && !isOwner} onChange={e => {
this.updateTicketField("content", e.target.value);
}} />
</Col>
</Row>
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}>
{i18next.t("general:User")}:
</Col>
<Col span={22} >
<Input value={this.state.ticket.user} disabled={true} />
</Col>
</Row>
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}>
{i18next.t("general:State")}:
</Col>
<Col span={22} >
<Select virtual={false} style={{width: "100%"}} value={this.state.ticket.state}
disabled={!isAdmin && this.state.ticket.state === "Closed"}
onChange={(value => {
this.updateTicketField("state", value);
})}>
<Option value="Open">{i18next.t("ticket:Open")}</Option>
<Option value="In Progress">{i18next.t("ticket:In Progress")}</Option>
<Option value="Resolved">{i18next.t("ticket:Resolved")}</Option>
<Option value="Closed">{i18next.t("ticket:Closed")}</Option>
</Select>
</Col>
</Row>
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}>
{i18next.t("general:Created time")}:
</Col>
<Col span={22} >
<Input value={this.state.ticket.createdTime} disabled={true} />
</Col>
</Row>
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}>
{i18next.t("general:Updated time")}:
</Col>
<Col span={22} >
<Input value={this.state.ticket.updatedTime} disabled={true} />
</Col>
</Row>
</Card>
);
}
renderMessages() {
return (
<Card size="small" title={i18next.t("ticket:Messages")} style={{marginTop: "20px", marginLeft: "5px"}} type="inner">
<List
itemLayout="horizontal"
dataSource={this.state.ticket.messages || []}
renderItem={(message, index) => (
<List.Item key={index}>
<List.Item.Meta
avatar={<Avatar icon={<UserOutlined />} style={{backgroundColor: message.isAdmin ? "#1890ff" : "#87d068"}} />}
title={
<Space>
<span>{message.author}</span>
{message.isAdmin && <Tag color="blue">{i18next.t("general:Admin")}</Tag>}
<span style={{fontSize: "12px", color: "#999"}}>{Setting.getFormattedDate(message.timestamp)}</span>
</Space>
}
description={
<div style={{whiteSpace: "pre-wrap", wordBreak: "break-word"}}>
{message.text}
</div>
}
/>
</List.Item>
)}
/>
<Divider />
<Row gutter={16}>
<Col span={20}>
<TextArea
rows={3}
value={this.state.messageText}
onChange={e => this.setState({messageText: e.target.value})}
placeholder={i18next.t("ticket:Type your message here...")}
onPressEnter={(e) => {
if (e.ctrlKey || e.metaKey) {
this.sendMessage();
}
}}
/>
</Col>
<Col span={4}>
<Button
type="primary"
icon={<SendOutlined />}
loading={this.state.sending}
onClick={() => this.sendMessage()}
style={{width: "100%", height: "100%"}}
>
{i18next.t("general:Send")}
</Button>
</Col>
</Row>
<div style={{marginTop: "8px", color: "#999", fontSize: "12px"}}>
{i18next.t("ticket:Press Ctrl+Enter to send")}
</div>
</Card>
);
}
render() {
return (
<div>
{
this.state.ticket !== null ? this.renderTicket() : null
}
<br />
{
this.state.ticket !== null ? this.renderMessages() : null
}
</div>
);
}
}
export default TicketEditPage;

253
web/src/TicketListPage.js Normal file
View File

@@ -0,0 +1,253 @@
// Copyright 2024 The Casdoor Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import React from "react";
import {Link} from "react-router-dom";
import {Button, Table} from "antd";
import {CheckCircleOutlined, ClockCircleOutlined, CloseCircleOutlined, SyncOutlined} from "@ant-design/icons";
import moment from "moment";
import * as Setting from "./Setting";
import * as TicketBackend from "./backend/TicketBackend";
import i18next from "i18next";
import BaseListPage from "./BaseListPage";
import PopconfirmModal from "./common/modal/PopconfirmModal";
class TicketListPage extends BaseListPage {
newTicket() {
const randomName = Setting.getRandomName();
const owner = Setting.getRequestOrganization(this.props.account);
return {
owner: owner,
name: `ticket_${randomName}`,
createdTime: moment().format(),
updatedTime: moment().format(),
displayName: `New Ticket - ${randomName}`,
user: this.props.account.name,
title: "",
content: "",
state: "Open",
messages: [],
};
}
addTicket() {
const newTicket = this.newTicket();
TicketBackend.addTicket(newTicket)
.then((res) => {
if (res.status === "ok") {
this.props.history.push({pathname: `/tickets/${newTicket.owner}/${newTicket.name}`, mode: "add"});
Setting.showMessage("success", i18next.t("general:Successfully added"));
} else {
Setting.showMessage("error", `${i18next.t("general:Failed to add")}: ${res.msg}`);
}
})
.catch(error => {
Setting.showMessage("error", `${i18next.t("general:Failed to connect to server")}: ${error}`);
});
}
deleteTicket(i) {
TicketBackend.deleteTicket(this.state.data[i])
.then((res) => {
if (res.status === "ok") {
Setting.showMessage("success", i18next.t("general:Successfully deleted"));
this.fetch({
pagination: {
...this.state.pagination,
current: this.state.pagination.current > 1 && this.state.data.length === 1 ? this.state.pagination.current - 1 : this.state.pagination.current,
},
});
} else {
Setting.showMessage("error", `${i18next.t("general:Failed to delete")}: ${res.msg}`);
}
})
.catch(error => {
Setting.showMessage("error", `${i18next.t("general:Failed to connect to server")}: ${error}`);
});
}
renderTable(tickets) {
const columns = [
{
title: i18next.t("general:Name"),
dataIndex: "name",
key: "name",
width: "140px",
fixed: "left",
sorter: true,
...this.getColumnSearchProps("name"),
render: (text, record, index) => {
return (
<Link to={`/tickets/${record.owner}/${text}`}>
{text}
</Link>
);
},
},
{
title: i18next.t("general:Display name"),
dataIndex: "displayName",
key: "displayName",
width: "200px",
sorter: true,
...this.getColumnSearchProps("displayName"),
},
{
title: i18next.t("general:Title"),
dataIndex: "title",
key: "title",
width: "200px",
sorter: true,
...this.getColumnSearchProps("title"),
},
{
title: i18next.t("general:User"),
dataIndex: "user",
key: "user",
width: "120px",
sorter: true,
...this.getColumnSearchProps("user"),
render: (text, record, index) => {
return (
<Link to={`/users/${record.owner}/${text}`}>
{text}
</Link>
);
},
},
{
title: i18next.t("general:Created time"),
dataIndex: "createdTime",
key: "createdTime",
width: "160px",
sorter: true,
render: (text, record, index) => {
return Setting.getFormattedDate(text);
},
},
{
title: i18next.t("general:Updated time"),
dataIndex: "updatedTime",
key: "updatedTime",
width: "160px",
sorter: true,
render: (text, record, index) => {
return Setting.getFormattedDate(text);
},
},
{
title: i18next.t("general:State"),
dataIndex: "state",
key: "state",
width: "120px",
sorter: true,
...this.getColumnSearchProps("state"),
render: (text, record, index) => {
switch (text) {
case "Open":
return Setting.getTag("processing", i18next.t("ticket:Open"), <ClockCircleOutlined />);
case "In Progress":
return Setting.getTag("warning", i18next.t("ticket:In Progress"), <SyncOutlined spin />);
case "Resolved":
return Setting.getTag("success", i18next.t("ticket:Resolved"), <CheckCircleOutlined />);
case "Closed":
return Setting.getTag("default", i18next.t("ticket:Closed"), <CloseCircleOutlined />);
default:
return null;
}
},
},
{
title: i18next.t("general:Action"),
dataIndex: "",
key: "op",
width: "180px",
fixed: (Setting.isMobile()) ? "false" : "right",
render: (text, record, index) => {
return (
<div>
<Button style={{marginTop: "10px", marginBottom: "10px", marginRight: "10px"}} onClick={() => this.props.history.push(`/tickets/${record.owner}/${record.name}`)}>{i18next.t("general:View")}</Button>
{Setting.isAdminUser(this.props.account) ? (
<PopconfirmModal
title={i18next.t("general:Sure to delete") + `: ${record.name} ?`}
onConfirm={() => this.deleteTicket(index)}
>
</PopconfirmModal>
) : null}
</div>
);
},
},
];
const paginationProps = {
total: this.state.pagination.total,
showQuickJumper: true,
showSizeChanger: true,
showTotal: () => i18next.t("general:{total} in total").replace("{total}", this.state.pagination.total),
};
return (
<div>
<Table scroll={{x: "max-content"}} columns={columns} dataSource={tickets} rowKey={(record) => `${record.owner}/${record.name}`} size="middle" bordered pagination={paginationProps}
title={() => (
<div>
{i18next.t("general:Tickets")}&nbsp;&nbsp;&nbsp;&nbsp;
<Button type="primary" size="small" onClick={this.addTicket.bind(this)}>{i18next.t("general:Add")}</Button>
</div>
)}
loading={this.state.loading}
onChange={this.handleTableChange}
/>
</div>
);
}
fetch = (params = {}) => {
let field = params.searchedColumn, value = params.searchText;
const sortField = params.sortField, sortOrder = params.sortOrder;
if (params.type !== undefined && params.type !== null) {
field = "type";
value = params.type;
}
this.setState({loading: true});
TicketBackend.getTickets(Setting.isDefaultOrganizationSelected(this.props.account) ? "" : Setting.getRequestOrganization(this.props.account), params.pagination.current, params.pagination.pageSize, field, value, sortField, sortOrder)
.then((res) => {
this.setState({
loading: false,
});
if (res.status === "ok") {
this.setState({
data: res.data,
pagination: {
...params.pagination,
total: res.data2,
},
searchText: params.searchText,
searchedColumn: params.searchedColumn,
});
} else {
if (Setting.isResponseDenied(res)) {
this.setState({
isAuthorized: false,
});
} else {
Setting.showMessage("error", res.msg);
}
}
});
};
}
export default TicketListPage;

View File

@@ -0,0 +1,82 @@
// Copyright 2024 The Casdoor Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import * as Setting from "../Setting";
export function getTickets(owner, page = "", pageSize = "", field = "", value = "", sortField = "", sortOrder = "") {
return fetch(`${Setting.ServerUrl}/api/get-tickets?owner=${owner}&p=${page}&pageSize=${pageSize}&field=${field}&value=${value}&sortField=${sortField}&sortOrder=${sortOrder}`, {
method: "GET",
credentials: "include",
headers: {
"Accept-Language": Setting.getAcceptLanguage(),
},
}).then(res => res.json());
}
export function getTicket(owner, name) {
return fetch(`${Setting.ServerUrl}/api/get-ticket?id=${owner}/${encodeURIComponent(name)}`, {
method: "GET",
credentials: "include",
headers: {
"Accept-Language": Setting.getAcceptLanguage(),
},
}).then(res => res.json());
}
export function updateTicket(owner, name, ticket) {
const newTicket = Setting.deepCopy(ticket);
return fetch(`${Setting.ServerUrl}/api/update-ticket?id=${owner}/${encodeURIComponent(name)}`, {
method: "POST",
credentials: "include",
body: JSON.stringify(newTicket),
headers: {
"Accept-Language": Setting.getAcceptLanguage(),
},
}).then(res => res.json());
}
export function addTicket(ticket) {
const newTicket = Setting.deepCopy(ticket);
return fetch(`${Setting.ServerUrl}/api/add-ticket`, {
method: "POST",
credentials: "include",
body: JSON.stringify(newTicket),
headers: {
"Accept-Language": Setting.getAcceptLanguage(),
},
}).then(res => res.json());
}
export function deleteTicket(ticket) {
const newTicket = Setting.deepCopy(ticket);
return fetch(`${Setting.ServerUrl}/api/delete-ticket`, {
method: "POST",
credentials: "include",
body: JSON.stringify(newTicket),
headers: {
"Accept-Language": Setting.getAcceptLanguage(),
},
}).then(res => res.json());
}
export function addTicketMessage(owner, name, message) {
return fetch(`${Setting.ServerUrl}/api/add-ticket-message?id=${owner}/${encodeURIComponent(name)}`, {
method: "POST",
credentials: "include",
body: JSON.stringify(message),
headers: {
"Accept-Language": Setting.getAcceptLanguage(),
},
}).then(res => res.json());
}

View File

@@ -488,6 +488,7 @@
"Syncers": "Syncers",
"System Info": "System Info",
"Tab": "Tab",
"Tickets": "Tickets",
"The actions cannot be empty": "The actions cannot be empty",
"The resources cannot be empty": "The resources cannot be empty",
"The users and roles cannot be empty at the same time": "The users and roles cannot be empty at the same time",
@@ -522,6 +523,7 @@
"Users - Tooltip": "Users",
"Users under all organizations": "Users under all organizations",
"Verifications": "Verifications",
"View": "View",
"Webhooks": "Webhooks",
"You can only select one physical group": "You can only select one physical group",
"You must select a picture first": "You must select a picture first",
@@ -1392,6 +1394,19 @@
"You have changed the username, please save your change first before modifying the password": "You have changed the username, please save your change first before modifying the password",
"input password": "input password"
},
"ticket": {
"Closed": "Closed",
"Edit Ticket": "Edit Ticket",
"In Progress": "In Progress",
"Messages": "Messages",
"New Ticket": "New Ticket",
"Open": "Open",
"Please enter a message": "Please enter a message",
"Press Ctrl+Enter to send": "Press Ctrl+Enter to send",
"Resolved": "Resolved",
"Ticket not found": "Ticket not found",
"Type your message here...": "Type your message here..."
},
"verification": {
"Is used": "Is used",
"Receiver": "Receiver"