Первичная имплементация админки

This commit is contained in:
Александр Кирюхин 2024-01-24 23:15:19 +03:00
parent e47115036e
commit 3b8cdbc2ea
21 changed files with 335 additions and 96 deletions

View file

@ -10,3 +10,5 @@ steps:
tags:
- latest
- ${CI_COMMIT_SHA}
build_args:
- VERSION=${CI_COMMIT_SHA}

View file

@ -122,8 +122,23 @@ paths:
schema:
type: string
format: binary
/games/{uid}:
post:
operationId: editGame
parameters:
- name: uid
in: path
required: true
schema:
type: string
format: uuid
security:
- cookieAuth: [creator, admin]
requestBody:
$ref: "#/components/requestBodies/gameEditRequest"
responses:
200:
$ref: "#/components/responses/gameResponse"
components:
schemas:
userView:
@ -218,15 +233,15 @@ components:
type: array
items:
$ref: '#/components/schemas/codeView'
solutions:
type: array
items:
$ref: '#/components/schemas/solutionView'
# solutions:
# type: array
# items:
# $ref: '#/components/schemas/solutionView'
required:
- title
- text
- codes
- solutions
# - solutions
codeView:
type: object
properties:
@ -281,15 +296,15 @@ components:
type: array
items:
$ref: '#/components/schemas/codeEdit'
solutions:
type: array
items:
$ref: '#/components/schemas/solutionEdit'
# solutions:
# type: array
# items:
# $ref: '#/components/schemas/solutionEdit'
required:
- title
- text
- codes
- solutions
# - solutions
codeEdit:
type: object
properties:
@ -300,16 +315,16 @@ components:
required:
- description
- code
solutionEdit:
type: object
properties:
text:
type: string
after:
type: integer
required:
- after
- text
# solutionEdit:
# type: object
# properties:
# text:
# type: string
# after:
# type: integer
# required:
# - after
# - text
gameType:
type: string
enum:

View file

@ -40,6 +40,9 @@ type ServerInterface interface {
// (POST /games)
CreateGame(ctx echo.Context) error
// (POST /games/{uid})
EditGame(ctx echo.Context, uid openapi_types.UUID) error
// (GET /user)
GetUser(ctx echo.Context) error
@ -145,6 +148,24 @@ func (w *ServerInterfaceWrapper) CreateGame(ctx echo.Context) error {
return err
}
// EditGame converts echo context to params.
func (w *ServerInterfaceWrapper) EditGame(ctx echo.Context) error {
var err error
// ------------- Path parameter "uid" -------------
var uid openapi_types.UUID
err = runtime.BindStyledParameterWithLocation("simple", false, "uid", runtime.ParamLocationPath, ctx.Param("uid"), &uid)
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter uid: %s", err))
}
ctx.Set(CookieAuthScopes, []string{"creator", "admin"})
// Invoke the callback with all the unmarshaled arguments
err = w.Handler.EditGame(ctx, uid)
return err
}
// GetUser converts echo context to params.
func (w *ServerInterfaceWrapper) GetUser(ctx echo.Context) error {
var err error
@ -219,6 +240,7 @@ func RegisterHandlersWithBaseURL(router EchoRouter, si ServerInterface, baseURL
router.GET(baseURL+"/file/:uid", wrapper.GetFile)
router.GET(baseURL+"/games", wrapper.GetGames)
router.POST(baseURL+"/games", wrapper.CreateGame)
router.POST(baseURL+"/games/:uid", wrapper.EditGame)
router.GET(baseURL+"/user", wrapper.GetUser)
router.POST(baseURL+"/user/login", wrapper.PostUserLogin)
router.POST(baseURL+"/user/logout", wrapper.PostUserLogout)
@ -229,26 +251,26 @@ func RegisterHandlersWithBaseURL(router EchoRouter, si ServerInterface, baseURL
// Base64 encoded, gzipped, json marshaled Swagger object
var swaggerSpec = []string{
"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",
"Ymo063X9bwAAAP//ZDhhHgMaAAA=",
}
// GetSwagger returns the content of the embedded swagger specification file

View file

@ -71,22 +71,9 @@ type GameView struct {
Type GameType `json:"type"`
}
// SolutionEdit defines model for solutionEdit.
type SolutionEdit struct {
After int `json:"after"`
Text string `json:"text"`
}
// SolutionView defines model for solutionView.
type SolutionView struct {
After int `json:"after"`
Text *string `json:"text,omitempty"`
}
// TaskEdit defines model for taskEdit.
type TaskEdit struct {
Codes []CodeEdit `json:"codes"`
Solutions []SolutionEdit `json:"solutions"`
Text string `json:"text"`
Title string `json:"title"`
}
@ -95,7 +82,6 @@ type TaskEdit struct {
type TaskView struct {
Codes []CodeView `json:"codes"`
Message *TaskViewMessage `json:"message,omitempty"`
Solutions []SolutionView `json:"solutions"`
Text string `json:"text"`
Title string `json:"title"`
}
@ -195,6 +181,9 @@ type UploadFileMultipartRequestBody UploadFileMultipartBody
// CreateGameJSONRequestBody defines body for CreateGame for application/json ContentType.
type CreateGameJSONRequestBody = GameEdit
// EditGameJSONRequestBody defines body for EditGame for application/json ContentType.
type EditGameJSONRequestBody = GameEdit
// PostUserLoginJSONRequestBody defines body for PostUserLogin for application/json ContentType.
type PostUserLoginJSONRequestBody PostUserLoginJSONBody

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.5 KiB

After

Width:  |  Height:  |  Size: 9.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.2 KiB

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.7 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9 KiB

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3 KiB

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.1 KiB

After

Width:  |  Height:  |  Size: 6.5 KiB

View file

@ -14,6 +14,7 @@ import Engine from './pages/Engine'
import Quests from './pages/Quests'
import User from './pages/User'
import { useRole } from './utils/roles'
import Quest from './pages/admin/Quest'
const router = createBrowserRouter(
createRoutesFromElements(
@ -48,7 +49,7 @@ const router = createBrowserRouter(
/>
<Route
path="quest/new"
element={<Auth role="creator"><NoMatch /></Auth>}
element={<Auth role="creator"><Quest /></Auth>}
// loader={() => ajax(`/api/admin/games`)}
/>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.3 KiB

View file

@ -2,9 +2,9 @@
font-size: 26px;
padding-top: 0 !important;
padding-bottom: 0 !important;
height: 40px;
width: 135px;
background-image: url("./logo_small.png");
height: 48px;
width: 54px;
background-image: url("./logo.png");
background-size: cover;
display: inline-block;
margin-right: 50px;

View file

@ -32,10 +32,10 @@ const Login = () => {
return (<>
<h1>Вход</h1>
{error ? <Alert type="error" message={error} /> : null}
{error ? <Alert type='error' message={error} /> : null}
<Form
form={form}
name="login"
name='login'
labelCol={{
span: 8
}}
@ -48,8 +48,8 @@ const Login = () => {
onFinish={onFinish}>
<Form.Item
label="E-mail"
name="email"
label='E-mail'
name='email'
rules={[
{
required: true,
@ -62,13 +62,13 @@ const Login = () => {
]}
>
<Input
type="email"
placeholder="name@mail.ru"
type='email'
placeholder='name@mail.ru'
/>
</Form.Item>
<Form.Item
label="Пароль"
name="password"
label='Пароль'
name='password'
rules={[
{
required: true,
@ -84,7 +84,7 @@ const Login = () => {
span: 16
}}
>
<Button type="primary" htmlType="submit">
<Button type='primary' htmlType='submit'>
Вход
</Button>
</Form.Item>

View file

@ -1,5 +1,177 @@
const Quest = () => {
import { useLoaderData, useNavigate } from 'react-router-dom'
import { Alert, Button, Card, Form, Input, InputNumber, Popconfirm, Radio, Typography, Upload } from 'antd'
import { UploadOutlined, PlusOutlined, CloseOutlined } from '@ant-design/icons'
import { ajax } from '../../utils/fetch'
import { useState } from 'react'
const { Title } = Typography
const Quest = () => {
let quest = useLoaderData()
const [error, setError] = useState()
const navigate = useNavigate()
const normFile = (e) => {
if (Array.isArray(e)) {
return e
}
if (e.file.response) {
return e.file.response.uuid
}
return e
}
const formItemLayout = {
labelCol: { span: 6 },
wrapperCol: { span: 14 }
}
const buttonLayout = {
offset: 6,
span: 14
}
const onFinish = (values) => {
ajax('/api/games', {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json'
},
body: JSON.stringify(values)
})
.then(g => navigate(`/quest/${g.id}/edit`))
.catch(({ message }) => setError('Ошибка создания'))
}
if (!quest) {
quest = {
type: 'city',
points: 10,
tasks: []
}
}
return (
<>
<Title>{quest.title ? (quest.title) : ('Новый квест')}</Title>
{error ? <Alert type="error" message={error} /> : null}
<Form
initialValues={quest}
onFinish={onFinish}
{...formItemLayout}
>
<Form.Item wrapperCol={buttonLayout}>
<Button type='primary' htmlType='submit' block>
Сохранить квест
</Button>
</Form.Item>
<Form.Item label='Название' name='title'>
<Input />
</Form.Item>
<Form.Item label='Описание' name='description' help='Поддерживается Markdown'>
<Input.TextArea />
</Form.Item>
<Form.Item
name='icon'
label='Иконка'
getValueFromEvent={normFile}
>
<Upload name='file' action='/api/file/upload' listType='picture' maxCount={1}>
<Button icon={<UploadOutlined />}>Загрузка</Button>
</Upload>
</Form.Item>
<Form.Item label='Тип квеста' name='type'>
<Radio.Group>
<Radio.Button value='city'>Полевой</Radio.Button>
<Radio.Button value='virtual'>Виртуальный</Radio.Button>
</Radio.Group>
</Form.Item>
<Form.Item label='Очков опыта за квест' name='points'>
<InputNumber />
</Form.Item>
<Form.List name='tasks'>
{(tasks, { add, remove }) => (
<>
{tasks.map(renderTaskForm(remove))}
<Form.Item wrapperCol={buttonLayout}>
<Button type='primary' onClick={() => add()} block>
<PlusOutlined/> Добавить уровень
</Button>
</Form.Item>
</>
)}
</Form.List>
</Form>
</>
)
}
// eslint-disable-next-line react/display-name
const renderTaskForm = remove => task => (
<Card
key={task.key}
title={`Уровень ${task.key}`}
style={{ marginBottom: 8 }}
actions={[
<Popconfirm
key={'removeLevel'}
title='Удалить уровень?'
onConfirm={() => remove(task.name)}
okText='Да'
cancelText='Нет'
>
<Button danger>
<CloseOutlined/> Удалить уровень
</Button>
</Popconfirm>
]}
>
<Form.Item name={[task.name, 'title']} label='Название уровня' help='ВИДНО игрокам'>
<Input />
</Form.Item>
<Form.Item name={[task.name, 'text']} label='Текст задания' help='Поддерживается Markdown'>
<Input.TextArea />
</Form.Item>
<Form.List name={[task.name, 'codes']}>
{(codes, codesOpts) => (
<>
{codes.map(renderCodeForm(codesOpts.remove))}
<Form.Item wrapperCol={{ offset: 6, span: 14 }}>
<Button key='addCode' type='primary' onClick={() => codesOpts.add()} block>
<PlusOutlined/> Добавить код
</Button>
</Form.Item>
</>
)}
</Form.List>
</Card>
)
// eslint-disable-next-line react/display-name
const renderCodeForm = remove => code => (
<Card
key={code.key}
style={{ marginBottom: 8 }}
actions={[
<Popconfirm
key='delete'
title='Удалить код?'
onConfirm={() => remove(code.name)}
okText='Да'
cancelText='Нет'
>
<Button danger>
<CloseOutlined/> Удалить код
</Button>
</Popconfirm>
]}
>
<Form.Item name={[code.name, 'code']} label='Код'>
<Input />
</Form.Item>
<Form.Item name={[code.name, 'description']} label='Описание кода' help='Видно игрокам всегда'>
<Input.TextArea />
</Form.Item>
</Card>
)
export default Quest

View file

@ -45,6 +45,35 @@ func (a *Admin) CreateGame(ctx echo.Context) error {
})
}
// (POST /games/{uid})
func (a *Admin) EditGame(ctx echo.Context, uid uuid.UUID) error {
user := contextlib.GetUser(ctx)
req := &api.GameEditRequest{}
if err := ctx.Bind(req); err != nil {
return err
}
game := a.mapCreateGameRequest(req, user)
var err error
game, err = a.GameService.UpdateGame(ctx.Request().Context(), uid, game)
if err != nil {
return err
}
return ctx.JSON(http.StatusCreated, api.GameResponse{
Authors: make([]api.UserView, 0, len(game.Authors)),
CreatedAt: game.CreatedAt.Format(time.RFC3339),
Description: game.Description,
Icon: game.IconID,
Id: game.ID,
Points: game.Points,
TaskCount: len(game.Tasks),
Title: game.Title,
Type: api.MapGameTypeReverse(game.Type),
})
}
func (*Admin) mapCreateGameRequest(req *api.GameEditRequest, user *models.User) *models.Game {
game := &models.Game{
Model: models.Model{
@ -69,19 +98,19 @@ func (*Admin) mapCreateGameRequest(req *api.GameEditRequest, user *models.User)
Title: te.Title,
Text: te.Text,
MaxTime: 0,
Solutions: make([]*models.Solution, 0, len(te.Solutions)),
// Solutions: make([]*models.Solution, 0, len(te.Solutions)),
Codes: make([]*models.Code, 0, len(te.Codes)),
TaskOrder: uint(order),
}
for _, s := range te.Solutions {
task.Solutions = append(task.Solutions, &models.Solution{
Model: models.Model{
ID: uuid.New(),
},
After: s.After,
Text: s.Text,
})
}
// for _, s := range te.Solutions {
// task.Solutions = append(task.Solutions, &models.Solution{
// Model: models.Model{
// ID: uuid.New(),
// },
// After: s.After,
// Text: s.Text,
// })
// }
for _, ce := range te.Codes {
task.Codes = append(task.Codes, &models.Code{
Model: models.Model{

View file

@ -77,7 +77,7 @@ func mapCursorToTask(cursor *models.GameCursor, message *api.TaskViewMessage) *a
resp := &api.TaskResponse{
Message: message,
Codes: make([]api.CodeView, 0, len(cursor.Task.Codes)),
Solutions: []api.SolutionView{},
// Solutions: []api.SolutionView{},
Text: cursor.Task.Text,
Title: cursor.Task.Title,
}

View file

@ -71,3 +71,12 @@ func (gs *Game) CreateGame(ctx context.Context, game *models.Game) (*models.Game
Create(game).
Error
}
func (gs *Game) UpdateGame(ctx context.Context, uid uuid.UUID, game *models.Game) (*models.Game, error) {
game.ID = uid
return game, gs.DB.
Session(&gorm.Session{FullSaveAssociations: true}).
Save(game).
Error
}