v2
This commit is contained in:
96
backend/app.js
Normal file
96
backend/app.js
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
const express = require('express')
|
||||||
|
const bodyParser = require('body-parser')
|
||||||
|
const cookieParser = require('cookie-parser')
|
||||||
|
const crypto = require('crypto')
|
||||||
|
const fs = require('fs')
|
||||||
|
const util = require('util')
|
||||||
|
const bot = require('./apps/bot')
|
||||||
|
|
||||||
|
const app = express()
|
||||||
|
|
||||||
|
app.use(bodyParser.json())
|
||||||
|
app.use(cookieParser())
|
||||||
|
|
||||||
|
BigInt.prototype.toJSON = function () {
|
||||||
|
return Number(this)
|
||||||
|
}
|
||||||
|
|
||||||
|
app.use((req, res, next) => {
|
||||||
|
if(!(req.body instanceof Object))
|
||||||
|
return next()
|
||||||
|
|
||||||
|
const escapeHtml = str => str.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''')
|
||||||
|
Object
|
||||||
|
.keys(req.body || {})
|
||||||
|
.filter(key => typeof(req.body[key]) == 'string' && key != 'password')
|
||||||
|
.map(key => req.body[key] = escapeHtml(req.body[key]))
|
||||||
|
|
||||||
|
next()
|
||||||
|
})
|
||||||
|
|
||||||
|
// cors
|
||||||
|
app.use((req, res, next) => {
|
||||||
|
res.set({
|
||||||
|
'Access-Control-Allow-Origin': '*',
|
||||||
|
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE',
|
||||||
|
'Access-Control-Allow-Headers': 'Accept,Accept-Language,Content-Language,Content-Type,Authorization,Cookie,X-Requested-With,Origin,Host',
|
||||||
|
'Access-Control-Allow-Credentials': true
|
||||||
|
})
|
||||||
|
|
||||||
|
return req.method == 'OPTIONS' ? res.status(200).json({success: true}) : next()
|
||||||
|
})
|
||||||
|
|
||||||
|
app.post('(/api/admin/customer/login|/api/miniapp/user/login)', (req, res, next) => {
|
||||||
|
const data = Object.assign({}, req.query)
|
||||||
|
delete data.hash
|
||||||
|
const hash = req.query?.hash
|
||||||
|
|
||||||
|
const BOT_TOKEN = '7236504417:AAGVaodw3cRwGlf-jAhwnYb51OHaXcgpW8k'
|
||||||
|
const dataCheckString = Object.keys(data).sort().map((key) => `${key}=${data[key]}`).join("\n")
|
||||||
|
const secretKey = crypto.createHmac("sha256", "WebAppData").update(BOT_TOKEN).digest()
|
||||||
|
const hmac = crypto.createHmac("sha256", secretKey).update(dataCheckString).digest("hex")
|
||||||
|
|
||||||
|
const timeDiff = Date.now() / 1000 - data.auth_date
|
||||||
|
|
||||||
|
if (hmac !== req.query.hash) // || timeDiff > 10)
|
||||||
|
throw Error('ACCESS_DENIED::401')
|
||||||
|
|
||||||
|
const user = JSON.parse(req.query.user)
|
||||||
|
res.locals.telegram_id = user.id
|
||||||
|
res.locals.start_param = req.query.start_param
|
||||||
|
|
||||||
|
if (!res.locals.telegram_id)
|
||||||
|
throw Error('ACCESS_DENIED::500')
|
||||||
|
|
||||||
|
next()
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
app.use('/api/admin', require('./apps/admin'))
|
||||||
|
app.use('/api/miniapp', require('./apps/miniapp'))
|
||||||
|
|
||||||
|
app.use((err, req, res, next) => {
|
||||||
|
console.error(`Error for ${req.path}: ${err}`)
|
||||||
|
|
||||||
|
let message, code
|
||||||
|
//if (err.code == 'SQLITE_ERROR' || err.code == 'SQLITE_CONSTRAINT_CHECK') {
|
||||||
|
// message = 'DATABASE_ERROR'
|
||||||
|
//code = err.code == 'SQLITE_CONSTRAINT_CHECK' ? 400 : 500
|
||||||
|
//} else {
|
||||||
|
[message, code = 500] = err.message.split('::')
|
||||||
|
//}
|
||||||
|
|
||||||
|
res.status(res.statusCode == 200 ? 500 : res.statusCode).json({success: false, error: { message, code}})
|
||||||
|
})
|
||||||
|
|
||||||
|
app.use(express.static('public'))
|
||||||
|
|
||||||
|
const PORT = process.env.PORT || 3000
|
||||||
|
app.listen(PORT, async () => {
|
||||||
|
console.log(`Listening at port ${PORT}`)
|
||||||
|
bot.start(
|
||||||
|
process.env.API_ID || 26746106,
|
||||||
|
process.env.API_HASH || '29e5f83c04e635fa583721473a6003b5',
|
||||||
|
process.env.BOT_TOKEN || '7236504417:AAGVaodw3cRwGlf-jAhwnYb51OHaXcgpW8k'
|
||||||
|
)
|
||||||
|
})
|
||||||
496
backend/apps/admin.js
Normal file
496
backend/apps/admin.js
Normal file
@@ -0,0 +1,496 @@
|
|||||||
|
const crypto = require('crypto')
|
||||||
|
const express = require('express')
|
||||||
|
const multer = require('multer')
|
||||||
|
const db = require('../include/db')
|
||||||
|
const bot = require('./bot')
|
||||||
|
const fs = require('fs')
|
||||||
|
const cookieParser = require('cookie-parser')
|
||||||
|
|
||||||
|
const app = express.Router()
|
||||||
|
const upload = multer({
|
||||||
|
storage: multer.memoryStorage(),
|
||||||
|
limits: {
|
||||||
|
fileSize: 1_000_000 // 1mb
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const sessions = {}
|
||||||
|
|
||||||
|
app.use((req, res, next) => {
|
||||||
|
if (req.path == '/customer/login' ||
|
||||||
|
req.path == '/customer/register' ||
|
||||||
|
req.path == '/customer/activate')
|
||||||
|
return next()
|
||||||
|
|
||||||
|
const asid = req.query.asid || req.cookies.asid
|
||||||
|
req.session = sessions[asid]
|
||||||
|
if (!req.session)
|
||||||
|
throw Error('ACCESS_DENIED::401')
|
||||||
|
|
||||||
|
res.locals.customer_id = req.session.customer_id
|
||||||
|
next()
|
||||||
|
})
|
||||||
|
|
||||||
|
// CUSTOMER
|
||||||
|
app.post('/customer/login', (req, res, next) => {
|
||||||
|
res.locals.email = req.body?.email
|
||||||
|
res.locals.password = req.body?.password
|
||||||
|
|
||||||
|
let customer_id = db
|
||||||
|
.prepare(`
|
||||||
|
select id
|
||||||
|
from customers
|
||||||
|
where is_active = 1 and (
|
||||||
|
email is not null and email = :email and password is not null and password = :password or
|
||||||
|
email is null and password is null and telegram_user_id = :telegram_id
|
||||||
|
)
|
||||||
|
`)
|
||||||
|
.pluck(true)
|
||||||
|
.get(res.locals)
|
||||||
|
|
||||||
|
if (!customer_id && !res.locals.email && !res.locals.password) {
|
||||||
|
customer_id = db
|
||||||
|
.prepare(`insert into customers (telegram_user_id, is_active) values (:telegram_id, 1) returning id`)
|
||||||
|
.safeIntegers(true)
|
||||||
|
.pluck(true)
|
||||||
|
.get(res.locals)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!customer_id)
|
||||||
|
throw Error('AUTH_ERROR::401')
|
||||||
|
|
||||||
|
res.locals.customer_id = customer_id
|
||||||
|
db
|
||||||
|
.prepare(`update customers set telegram_user_id = :telegram_id where id = :customer_id and email is not null`)
|
||||||
|
.run(res.locals)
|
||||||
|
|
||||||
|
const asid = crypto.randomBytes(64).toString('hex')
|
||||||
|
req.session = sessions[asid] = {asid, customer_id }
|
||||||
|
res.setHeader('Set-Cookie', [`asid=${asid};httpOnly;path=/api/admin`])
|
||||||
|
|
||||||
|
res.status(200).json({success: true})
|
||||||
|
})
|
||||||
|
|
||||||
|
app.get('/customer/logout', (req, res, next) => {
|
||||||
|
delete sessions[req.session.asid]
|
||||||
|
res.setHeader('Set-Cookie', [`asid=; expired; httpOnly`])
|
||||||
|
res.status(200).json({success: true})
|
||||||
|
})
|
||||||
|
|
||||||
|
app.post('/customer/register', (req, res, next) => {
|
||||||
|
const email = String(req.body.email).trim()
|
||||||
|
const password = String(req.body.password).trim()
|
||||||
|
|
||||||
|
const validateEmail = email => String(email).toLowerCase().match(/^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/)
|
||||||
|
if (!validateEmail(email))
|
||||||
|
throw Error('INCORRECT_EMAIL::400')
|
||||||
|
|
||||||
|
if (!password)
|
||||||
|
throw Error('EMPTY_PASSWORD::400')
|
||||||
|
|
||||||
|
const row = db
|
||||||
|
.prepare('select id from customers where email = :email')
|
||||||
|
.run({email})
|
||||||
|
if (row)
|
||||||
|
throw Error('DUPLICATE_EMAIL::400')
|
||||||
|
|
||||||
|
const key = crypto.randomBytes(32).toString('hex')
|
||||||
|
const info = db
|
||||||
|
.prepare('insert into customers (email, password, activation_key) values (:email, :password, :key)')
|
||||||
|
.run({email, password, key})
|
||||||
|
|
||||||
|
// To-Do: SEND MAIL
|
||||||
|
console.log(`http://127.0.0.1:3000/api/customer/activate?key=${key}`)
|
||||||
|
res.status(200).json({success: true, data: key})
|
||||||
|
})
|
||||||
|
|
||||||
|
app.get('/customer/activate', (req, res, next) => {
|
||||||
|
const row = db
|
||||||
|
.prepare('update customers set is_active = 1 where activation_key = :key returning id')
|
||||||
|
.get({key: req.query.key})
|
||||||
|
|
||||||
|
if (!row || !row.id)
|
||||||
|
throw Error('BAD_ACTIVATION_KEY::400')
|
||||||
|
|
||||||
|
res.status(200).json({success: true})
|
||||||
|
})
|
||||||
|
|
||||||
|
app.get('/customer/profile', (req, res, next) => {
|
||||||
|
const row = db
|
||||||
|
.prepare(`
|
||||||
|
select id, name, email, plan, coalesce(json_balance, '{}') json_balance, coalesce(json_company, '{}') json_company, upload_group_id
|
||||||
|
from customers
|
||||||
|
where id = :customer_id and is_active = 1
|
||||||
|
`)
|
||||||
|
.get(res.locals)
|
||||||
|
|
||||||
|
if (row?.upload_group_id) {
|
||||||
|
row.upload_group = db
|
||||||
|
.prepare(`select id, name, telegram_id from groups where id = :group_id and project_id is null`)
|
||||||
|
.safeIntegers(true)
|
||||||
|
.get({ group_id: row.upload_group_id})
|
||||||
|
delete row.upload_group_id
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const key in row) {
|
||||||
|
if (key.startsWith('json_')) {
|
||||||
|
row[key.substr(5)] = JSON.parse(row[key])
|
||||||
|
delete row[key]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(200).json({success: true, data: row})
|
||||||
|
})
|
||||||
|
|
||||||
|
app.put('/customer/profile', (req, res, next) => {
|
||||||
|
if (req.body.company instanceof Object)
|
||||||
|
req.body.json_company = JSON.stringify(req.body.company)
|
||||||
|
|
||||||
|
const info = db
|
||||||
|
.prepareUpdate(
|
||||||
|
'customers',
|
||||||
|
['name', 'password', 'json_company'],
|
||||||
|
req.body,
|
||||||
|
['id'])
|
||||||
|
.run(Object.assign({}, req.body, {id: req.session.customer_id}))
|
||||||
|
|
||||||
|
if (info.changes == 0)
|
||||||
|
throw Error('NOT_FOUND::404')
|
||||||
|
|
||||||
|
res.status(200).json({success: true})
|
||||||
|
})
|
||||||
|
|
||||||
|
// PROJECT
|
||||||
|
app.get('/project', (req, res, next) => {
|
||||||
|
const where = req.query.id ? ' and id = ' + parseInt(req.query.id) : ''
|
||||||
|
|
||||||
|
const rows = db
|
||||||
|
.prepare(`
|
||||||
|
select id, name, description, logo
|
||||||
|
from projects
|
||||||
|
where customer_id = :customer_id ${where} and is_deleted <> 1
|
||||||
|
order by name
|
||||||
|
`)
|
||||||
|
.all(res.locals)
|
||||||
|
|
||||||
|
if (where && rows.length == 0)
|
||||||
|
throw Error('NOT_FOUND::404')
|
||||||
|
|
||||||
|
res.status(200).json({success: true, data: where ? rows[0] : rows})
|
||||||
|
})
|
||||||
|
|
||||||
|
app.get('/project/:pid(\\d+)', (req, res, next) => {
|
||||||
|
res.redirect(req.baseUrl + `/project?id=${req.params.pid}`)
|
||||||
|
})
|
||||||
|
|
||||||
|
app.post('/project', (req, res, next) => {
|
||||||
|
res.locals.name = req.body?.name
|
||||||
|
res.locals.description = req.body?.description
|
||||||
|
res.locals.logo = req.body?.logo
|
||||||
|
|
||||||
|
const id = db
|
||||||
|
.prepare(`
|
||||||
|
insert into projects (customer_id, name, description, logo)
|
||||||
|
values (:customer_id, :name, :description, :logo)
|
||||||
|
returning id
|
||||||
|
`)
|
||||||
|
.pluck(true)
|
||||||
|
.get(res.locals)
|
||||||
|
|
||||||
|
res.status(200).json({success: true, data: id})
|
||||||
|
})
|
||||||
|
|
||||||
|
app.put('/project/:pid(\\d+)', (req, res, next) => {
|
||||||
|
res.locals.id = req.params.pid
|
||||||
|
res.locals.name = req.body?.name
|
||||||
|
res.locals.description = req.body?.description
|
||||||
|
res.locals.logo = req.body?.logo
|
||||||
|
|
||||||
|
const info = db
|
||||||
|
.prepareUpdate(
|
||||||
|
'projects',
|
||||||
|
['name', 'description', 'logo'],
|
||||||
|
res.locals,
|
||||||
|
['id', 'customer_id'])
|
||||||
|
.run(res.locals)
|
||||||
|
|
||||||
|
if (info.changes == 0)
|
||||||
|
throw Error('NOT_FOUND::404')
|
||||||
|
|
||||||
|
res.status(200).json({success: true})
|
||||||
|
})
|
||||||
|
|
||||||
|
app.delete('/project/:pid(\\d+)', async (req, res, next) => {
|
||||||
|
res.locals.id = req.params.pid
|
||||||
|
|
||||||
|
const info = db
|
||||||
|
.prepare('update projects set id_deleted = 1 where id = :id and customer_id = :customer_id')
|
||||||
|
.run(res.locals)
|
||||||
|
|
||||||
|
if (info.changes == 0)
|
||||||
|
throw Error('NOT_FOUND::404')
|
||||||
|
|
||||||
|
const groupIds = db
|
||||||
|
.prepare(`select id from groups where project_id = :id`)
|
||||||
|
.pluck(true)
|
||||||
|
.all(res.locals)
|
||||||
|
|
||||||
|
for (const groupId of groupIds) {
|
||||||
|
await bot.sendMessage(groupId, 'Проект удален')
|
||||||
|
await bot.leaveGroup(groupId)
|
||||||
|
}
|
||||||
|
|
||||||
|
db.prepare(`updates groups set project_id = null where id in (${ groupIds.join(', ')})`).run()
|
||||||
|
|
||||||
|
res.status(200).json({success: true})
|
||||||
|
})
|
||||||
|
|
||||||
|
app.use ('/project/:pid(\\d+)/*', (req, res, next) => {
|
||||||
|
res.locals.project_id = parseInt(req.params.pid)
|
||||||
|
|
||||||
|
const row = db
|
||||||
|
.prepare('select 1 from projects where id = :project_id and customer_id = :customer_id and is_deleted <> 1')
|
||||||
|
.get(res.locals)
|
||||||
|
|
||||||
|
if (!row)
|
||||||
|
throw Error('ACCESS_DENIED::401')
|
||||||
|
|
||||||
|
next()
|
||||||
|
})
|
||||||
|
|
||||||
|
// USER
|
||||||
|
app.get('/project/:pid(\\d+)/user', (req, res, next) => {
|
||||||
|
const where = req.query.id ? ' and id = ' + parseInt(req.query.id) : ''
|
||||||
|
|
||||||
|
const rows = db
|
||||||
|
.prepare(`
|
||||||
|
select u.id, u.telegram_id, u.firstname, u.lastname, u.username, u.photo,
|
||||||
|
ud.fullname, ud.role, ud.department, ud.is_blocked
|
||||||
|
from users u
|
||||||
|
left join user_details ud on u.id = ud.user_id and ud.project_id = :project_id
|
||||||
|
where id in (
|
||||||
|
select user_id
|
||||||
|
from group_users
|
||||||
|
where group_id in (select id from groups where project_id = :project_id)
|
||||||
|
) ${where}
|
||||||
|
`)
|
||||||
|
.safeIntegers(true)
|
||||||
|
.all(res.locals)
|
||||||
|
|
||||||
|
if (where && rows.length == 0)
|
||||||
|
throw Error('NOT_FOUND::404')
|
||||||
|
|
||||||
|
res.status(200).json({success: true, data: where ? rows[0] : rows})
|
||||||
|
})
|
||||||
|
|
||||||
|
app.get('/project/:pid(\\d+)/user/:uid(\\d+)', (req, res, next) => {
|
||||||
|
res.redirect(req.baseUrl + `/project/${req.params.pid}/user?id=${req.params.uid}`)
|
||||||
|
})
|
||||||
|
|
||||||
|
app.put('/project/:pid(\\d+)/user/:uid(\\d+)', (req, res, next) => {
|
||||||
|
res.locals.user_id = parseInt(req.params.uid)
|
||||||
|
|
||||||
|
res.locals.fullname = req.body?.fullname
|
||||||
|
res.locals.role = req.body?.role
|
||||||
|
res.locals.department = req.body?.department
|
||||||
|
res.locals.is_blocked = req.body?.is_blocked
|
||||||
|
|
||||||
|
const info = db
|
||||||
|
.prepareUpdate('user_details',
|
||||||
|
['fullname', 'role', 'department', 'is_blocked'],
|
||||||
|
res.locals,
|
||||||
|
['user_id', 'project_id']
|
||||||
|
)
|
||||||
|
.all(res.locals)
|
||||||
|
|
||||||
|
if (info.changes == 0)
|
||||||
|
throw Error('NOT_FOUND::404')
|
||||||
|
|
||||||
|
res.status(200).json({success: true})
|
||||||
|
})
|
||||||
|
|
||||||
|
app.get('/project/:pid(\\d+)/token', (req, res, next) => {
|
||||||
|
res.locals.time = Math.floor(Date.now() / 1000)
|
||||||
|
|
||||||
|
const key = db
|
||||||
|
.prepare('select generate_key(id, :time) from projects where id = :project_id and customer_id = :customer_id')
|
||||||
|
.pluck(true)
|
||||||
|
.get(res.locals)
|
||||||
|
|
||||||
|
if (!key)
|
||||||
|
throw Error('NOT_FOUND::404')
|
||||||
|
|
||||||
|
res.status(200).json({success: true, data: key})
|
||||||
|
})
|
||||||
|
|
||||||
|
// COMPANY
|
||||||
|
app.get('/project/:pid(\\d+)/company', (req, res, next) => {
|
||||||
|
const where = req.query.id ? ' and id = ' + parseInt(req.query.id) : ''
|
||||||
|
|
||||||
|
const rows = db
|
||||||
|
.prepare(`
|
||||||
|
select id, name, email, phone, description, logo,
|
||||||
|
(select json_group_array(user_id) from company_users where company_id = c.id) users
|
||||||
|
from companies c
|
||||||
|
where project_id = :project_id ${where}
|
||||||
|
order by name
|
||||||
|
`)
|
||||||
|
.all(res.locals)
|
||||||
|
|
||||||
|
rows.forEach(row => row.users = JSON.parse(row.users || '[]'))
|
||||||
|
|
||||||
|
if (where && rows.length == 0)
|
||||||
|
throw Error('NOT_FOUND::404')
|
||||||
|
|
||||||
|
res.status(200).json({success: true, data: where ? rows[0] : rows})
|
||||||
|
})
|
||||||
|
|
||||||
|
app.get('/project/:pid(\\d+)/company/:cid(\\d+)', (req, res, next) => {
|
||||||
|
res.redirect(req.baseUrl + `/project/${req.params.pid}/company?id=${req.params.cid}`)
|
||||||
|
})
|
||||||
|
|
||||||
|
app.post('/project/:pid(\\d+)/company', (req, res, next) => {
|
||||||
|
res.locals.name = req.body?.name
|
||||||
|
res.locals.email = req.body?.email
|
||||||
|
res.locals.phone = req.body?.phone
|
||||||
|
res.locals.site = req.body?.site
|
||||||
|
res.locals.description = req.body?.description
|
||||||
|
res.locals.logo = req.body?.logo
|
||||||
|
|
||||||
|
const id = db
|
||||||
|
.prepare(`
|
||||||
|
insert into companies (project_id, name, email, phone, site, description, logo)
|
||||||
|
values (:project_id, :name, :email, :phone, :site, :description, :logo)
|
||||||
|
returning id
|
||||||
|
`)
|
||||||
|
.pluck(res.locals)
|
||||||
|
.get(res.locals)
|
||||||
|
|
||||||
|
res.status(200).json({success: true, data: id})
|
||||||
|
})
|
||||||
|
|
||||||
|
app.put('/project/:pid(\\d+)/company/:cid(\\d+)', (req, res, next) => {
|
||||||
|
res.locals.id = parseInt(req.params.cid)
|
||||||
|
res.locals.name = req.body?.name
|
||||||
|
res.locals.email = req.body?.email
|
||||||
|
res.locals.phone = req.body?.phone
|
||||||
|
res.locals.site = req.body?.site
|
||||||
|
res.locals.description = req.body?.description
|
||||||
|
|
||||||
|
const info = db
|
||||||
|
.prepareUpdate(
|
||||||
|
'companies',
|
||||||
|
['name', 'email', 'phone', 'site', 'description'],
|
||||||
|
res.locals,
|
||||||
|
['id', 'project_id'])
|
||||||
|
.run(res.locals)
|
||||||
|
|
||||||
|
if (info.changes == 0)
|
||||||
|
throw Error('NOT_FOUND::404')
|
||||||
|
|
||||||
|
res.status(200).json({success: true})
|
||||||
|
})
|
||||||
|
|
||||||
|
app.delete('/project/:pid(\\d+)/company/:cid(\\d+)', (req, res, next) => {
|
||||||
|
res.locals.company_id = parseInt(req.params.cid)
|
||||||
|
|
||||||
|
const info = db
|
||||||
|
.prepare(`delete from companies where id = :company_id and project_id = :project_id`)
|
||||||
|
.run(res.locals)
|
||||||
|
|
||||||
|
if (info.changes == 0)
|
||||||
|
throw Error('NOT_FOUND::404')
|
||||||
|
|
||||||
|
res.status(200).json({success: true})
|
||||||
|
})
|
||||||
|
|
||||||
|
app.get('/project/:pid(\\d+)/group', (req, res, next) => {
|
||||||
|
const where = req.query.id ? ' and id = ' + parseInt(req.query.id) : ''
|
||||||
|
|
||||||
|
const rows = db
|
||||||
|
.prepare(`
|
||||||
|
select id, name, telegram_id, is_channel, user_count, bot_can_ban
|
||||||
|
from groups
|
||||||
|
where project_id = :project_id ${where}
|
||||||
|
`)
|
||||||
|
.all(res.locals)
|
||||||
|
|
||||||
|
if (where && rows.length == 0)
|
||||||
|
throw Error('NOT_FOUND::404')
|
||||||
|
|
||||||
|
res.status(200).json({success: true, data: where ? rows[0] : rows})
|
||||||
|
})
|
||||||
|
|
||||||
|
app.get('/project/:pid(\\d+)/group/:gid(\\d+)', (req, res, next) => {
|
||||||
|
res.redirect(req.baseUrl + `/project/${req.params.pid}/group?id=${req.params.uid}`)
|
||||||
|
})
|
||||||
|
|
||||||
|
app.delete('/project/:pid(\\d+)/group/:gid(\\d+)', async (req, res, next) => {
|
||||||
|
res.locals.group_id = parseInt(req.params.gid)
|
||||||
|
const info = db
|
||||||
|
.prepare(`update groups set project_id = null where id = :group_id and project_id = :project_id`)
|
||||||
|
.run(res.locals)
|
||||||
|
|
||||||
|
if (info.changes == 0)
|
||||||
|
throw Error('NOT_FOUND::404')
|
||||||
|
|
||||||
|
await bot.sendMessage(res.locals.group_id, 'Группа удалена из проекта')
|
||||||
|
|
||||||
|
res.status(200).json({success: true})
|
||||||
|
})
|
||||||
|
|
||||||
|
app.put('/project/:pid(\\d+)/company/:cid(\\d+)/user', (req, res, next) => {
|
||||||
|
res.locals.company_id = parseInt(req.params.cid)
|
||||||
|
|
||||||
|
// Проверка, что есть доступ к компании
|
||||||
|
const row = db
|
||||||
|
.prepare('select 1 from companies where id = :company_id and project_id = :project_id')
|
||||||
|
.get(res.locals)
|
||||||
|
|
||||||
|
if (!row)
|
||||||
|
throw Error('NOT_FOUND::404')
|
||||||
|
|
||||||
|
const user_ids = req.body instanceof Array ? [...new Set(req.body.map(e => parseInt(e)))] : []
|
||||||
|
|
||||||
|
// Проверка, что пользователи имеют доступ к проекту
|
||||||
|
let rows = db
|
||||||
|
.prepare(`
|
||||||
|
select user_id
|
||||||
|
from group_users
|
||||||
|
where group_id in (select id from groups where project_id = :project_id)
|
||||||
|
`)
|
||||||
|
.pluck(true) // .raw?
|
||||||
|
.get(res.locals)
|
||||||
|
|
||||||
|
if (user_ids.some(user_id => !rows.contains(user_id)))
|
||||||
|
throw Error('INACCESSABLE_MEMBER::400')
|
||||||
|
|
||||||
|
// Проверка, что пользователи не участвуют в других компаниях на проекте
|
||||||
|
rows = db
|
||||||
|
.prepare(`
|
||||||
|
select user_id
|
||||||
|
from company_users
|
||||||
|
where company_id in (select id from companies where id <> :company_id and project_id = :project_id)
|
||||||
|
`)
|
||||||
|
.pluck(true) // .raw?
|
||||||
|
.get(res.locals)
|
||||||
|
|
||||||
|
if (user_ids.some(user_id => !rows.contains(user_id)))
|
||||||
|
throw Error('USED_MEMBER::400')
|
||||||
|
|
||||||
|
|
||||||
|
db
|
||||||
|
.prepare(`delete from company_users where company_id = :company_id`)
|
||||||
|
.run(res.locals)
|
||||||
|
|
||||||
|
db
|
||||||
|
.prepare(`
|
||||||
|
insert into company_users (company_id, user_id)
|
||||||
|
select :company_id, value from json_each(:json_ids)
|
||||||
|
`)
|
||||||
|
.run(res.locals, {json_ids: JSON.stringify(user_ids)})
|
||||||
|
|
||||||
|
res.status(200).json({success: true})
|
||||||
|
})
|
||||||
|
|
||||||
|
module.exports = app
|
||||||
654
backend/apps/bot.js
Normal file
654
backend/apps/bot.js
Normal file
@@ -0,0 +1,654 @@
|
|||||||
|
const util = require('util')
|
||||||
|
const crypto = require('crypto')
|
||||||
|
const EventEmitter = require('events')
|
||||||
|
const fs = require('fs')
|
||||||
|
const db = require('../include/db')
|
||||||
|
|
||||||
|
const { Api, TelegramClient } = require('telegram')
|
||||||
|
const { StringSession } = require('telegram/sessions')
|
||||||
|
const { NewMessage } = require('telegram/events')
|
||||||
|
const { Button } = require('telegram/tl/custom/button')
|
||||||
|
const { CustomFile } = require('telegram/client/uploads')
|
||||||
|
|
||||||
|
// const session = new StringSession('1AgAOMTQ5LjE1NC4xNjcuNTABuxdIxmjimA0hmWpdrlZ4Fo7uoIGU4Bu9+G5QprS6zdtyeMfcssWEZp0doLRX/20MomQyF4Opsos0El0Ifj5aiNgg01z8khMLMeT98jS+1U/sh32p3GxZfxyXSxX1bD0NLRaXnqVyNNswYqRZPhboT28NMjDqwlz0nrW9rge+QMJDL7jIkXgSs+cmJBINiqsEI8jWjXmc8TU/17gngtjUHRf5kRM4y5gsNC4O8cF5lcHRx0G/U5ZVihTID8ItQ6EdEHjz6e4XErbVOJ81PfYkqEoPXVvkEmRM0/VbvCzFfixfas4Vzczfn98OHLd8P2MXcgokZ2rppvIV3fQXOHxJbA0=')
|
||||||
|
const session = new StringSession('')
|
||||||
|
|
||||||
|
let client
|
||||||
|
|
||||||
|
function registerUser (telegramId) {
|
||||||
|
db
|
||||||
|
.prepare(`insert or ignore into users (telegram_id) values (:telegram_id)`)
|
||||||
|
.safeIntegers(true)
|
||||||
|
.run({ telegram_id: telegramId })
|
||||||
|
|
||||||
|
return db
|
||||||
|
.prepare(`select id from users where telegram_id = :telegram_id`)
|
||||||
|
.safeIntegers(true)
|
||||||
|
.pluck(true)
|
||||||
|
.get({ telegram_id: telegramId })
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateUser (userId, data) {
|
||||||
|
const info = db
|
||||||
|
.prepare(`
|
||||||
|
update users set firstname = :firstname, lastname = :lastname, username = :username,
|
||||||
|
access_hash = :access_hash, language_code = :language_code where id = :user_id
|
||||||
|
`)
|
||||||
|
.safeIntegers(true)
|
||||||
|
.run({
|
||||||
|
user_id: userId,
|
||||||
|
firstname: data.firstName,
|
||||||
|
lastname: data.lastName,
|
||||||
|
username: data.username,
|
||||||
|
access_hash: data.accessHash.value,
|
||||||
|
language_code: data.langCode
|
||||||
|
})
|
||||||
|
|
||||||
|
return info.changes == 1
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateUserPhoto (userId, data) {
|
||||||
|
const photoId = data.photo?.photoId?.value
|
||||||
|
if (!photoId)
|
||||||
|
return
|
||||||
|
|
||||||
|
const tgUserId = db
|
||||||
|
.prepare(`select telegram_id from users where id = :user_id and coalesce(photo_id, 0) <> :photo_id`)
|
||||||
|
.safeIntegers(true)
|
||||||
|
.pluck(true)
|
||||||
|
.get({user_id: userId, photo_id: photoId })
|
||||||
|
|
||||||
|
if (!tgUserId)
|
||||||
|
return
|
||||||
|
|
||||||
|
const photo = await client.invoke(new Api.photos.GetUserPhotos({
|
||||||
|
userId: new Api.PeerUser({ userId: tgUserId }),
|
||||||
|
maxId: photoId,
|
||||||
|
offset: -1,
|
||||||
|
limit: 1,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const file = await client.downloadFile(new Api.InputPhotoFileLocation({
|
||||||
|
id: photoId,
|
||||||
|
accessHash: photo.photos[0]?.accessHash,
|
||||||
|
fileReference: Buffer.from('random'),
|
||||||
|
thumbSize: 'a',
|
||||||
|
}, {}))
|
||||||
|
|
||||||
|
db
|
||||||
|
.prepare(`update users set photo_id = :photo_id, photo = :photo where id = :user_id`)
|
||||||
|
.safeIntegers(true)
|
||||||
|
.run({ user_id: userId, photo_id: photoId, photo: file.toString('base64') })
|
||||||
|
}
|
||||||
|
|
||||||
|
async function registerGroup (telegramId, isChannel) {
|
||||||
|
db
|
||||||
|
.prepare(`insert or ignore into groups (telegram_id, is_channel) values (:telegram_id, :is_channel)`)
|
||||||
|
.safeIntegers(true)
|
||||||
|
.run({ telegram_id: telegramId, is_channel: +isChannel })
|
||||||
|
|
||||||
|
const row = db
|
||||||
|
.prepare(`select id, name from groups where telegram_id = :telegram_id`)
|
||||||
|
.safeIntegers(true)
|
||||||
|
.get({telegram_id: telegramId})
|
||||||
|
|
||||||
|
if (!row?.name) {
|
||||||
|
const entity = isChannel ? { channelId: telegramId } : { chatId: telegramId }
|
||||||
|
const group = await client.getEntity(isChannel ? new Api.PeerChannel(entity) : new Api.PeerChat(entity))
|
||||||
|
|
||||||
|
db
|
||||||
|
.prepare(`update groups set name = :name where id = :group_id`)
|
||||||
|
.run({ group_id: row.id, name: group.title })
|
||||||
|
}
|
||||||
|
|
||||||
|
return row.id
|
||||||
|
}
|
||||||
|
|
||||||
|
async function attachGroup(groupId, isChannel, projectId) {
|
||||||
|
const info = db
|
||||||
|
.prepare(`update groups set project_id = :project_id where id = :group_id and coalesce(project_id, 1) = 1`)
|
||||||
|
.run({ group_id: groupId, project_id: projectId })
|
||||||
|
|
||||||
|
if (info.changes == 1) {
|
||||||
|
const inputPeer = isChannel ?
|
||||||
|
new Api.InputPeerChannel({ channelId: tgGroupId }) :
|
||||||
|
new Api.InputPeerChat({ chatlId: tgGroupId })
|
||||||
|
|
||||||
|
const query = `select (select name from customers where id = p.customer_id) || ' >> ' || p.name from projects p where id = :project_id`
|
||||||
|
const message = db
|
||||||
|
.prepare(query)
|
||||||
|
.pluck(true)
|
||||||
|
.get({project_id: projectId})
|
||||||
|
if (message)
|
||||||
|
await client.sendMessage(inputPeer, {message})
|
||||||
|
}
|
||||||
|
|
||||||
|
return info.changes == 1
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onGroupAttach (tgGroupId, isChannel) {
|
||||||
|
const projectId = db
|
||||||
|
.prepare(`select project_id from groups where telegram_id = :telegram_id`)
|
||||||
|
.safeIntegers(true)
|
||||||
|
.pluck(true)
|
||||||
|
.get({ telegram_id: tgGroupId })
|
||||||
|
|
||||||
|
const entity = isChannel ? { channelId: tgGroupId } : { chatId: tgGroupId }
|
||||||
|
const inputPeer = await client.getEntity( isChannel ?
|
||||||
|
new Api.InputPeerChannel(entity) :
|
||||||
|
new Api.InputPeerChat(entity)
|
||||||
|
)
|
||||||
|
|
||||||
|
const resultBtn = await client.sendMessage(inputPeer, {
|
||||||
|
message: 'ReadyOrNot',
|
||||||
|
buttons: client.buildReplyMarkup([[Button.url('Открыть проект', 'https://t.me/ready_or_not_2025_bot/userapp?startapp=user_' + projectId)]])
|
||||||
|
})
|
||||||
|
|
||||||
|
await client.invoke(new Api.messages.UpdatePinnedMessage({
|
||||||
|
peer: inputPeer,
|
||||||
|
id: resultBtn.id,
|
||||||
|
unpin: false
|
||||||
|
}))
|
||||||
|
|
||||||
|
//fs.appendFileSync('./1.log', '\n>' + tgGroupId + ':' + isChannel + '<\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function reloadGroupUsers(groupId, onlyReset) {
|
||||||
|
db
|
||||||
|
.prepare(`delete from group_users where group_id = :group_id`)
|
||||||
|
.run({ group_id: groupId })
|
||||||
|
|
||||||
|
if (onlyReset)
|
||||||
|
return
|
||||||
|
|
||||||
|
const group = db
|
||||||
|
.prepare(`select telegram_id, is_channel, access_hash from groups where id = :group_id`)
|
||||||
|
.get({ group_id: groupId})
|
||||||
|
|
||||||
|
console.log (123, group)
|
||||||
|
|
||||||
|
if (!group)
|
||||||
|
return
|
||||||
|
|
||||||
|
const tgGroupId = group.telegram_id
|
||||||
|
const isChannel = group.is_channel
|
||||||
|
let accessHash = group.access_hash
|
||||||
|
|
||||||
|
console.log ('HERE')
|
||||||
|
|
||||||
|
db
|
||||||
|
.prepare(`update groups set access_hash = :access_hash where id = :group_id`)
|
||||||
|
.safeIntegers(true)
|
||||||
|
.run({
|
||||||
|
group_id: groupId,
|
||||||
|
access_hash: accessHash,
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = isChannel ?
|
||||||
|
await client.invoke(new Api.channels.GetParticipants({
|
||||||
|
channel: new Api.PeerChannel({ channelId: tgGroupId }),
|
||||||
|
filter: new Api.ChannelParticipantsRecent(),
|
||||||
|
limit: 999999,
|
||||||
|
offset: 0
|
||||||
|
})) : await client.invoke(new Api.messages.GetFullChat({
|
||||||
|
chatId: tgGroupId,
|
||||||
|
}))
|
||||||
|
|
||||||
|
const users = result.users.filter(user => !user.bot)
|
||||||
|
for (const user of users) {
|
||||||
|
const userId = registerUser(user.id.value, user)
|
||||||
|
|
||||||
|
if (updateUser(userId, user)) {
|
||||||
|
await updateUserPhoto (userId, user)
|
||||||
|
|
||||||
|
const query = `insert or ignore into group_users (group_id, user_id) values (:group_id, :user_id)`
|
||||||
|
db.prepare(query).run({ group_id: groupId, user_id: userId })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function registerUpload(data) {
|
||||||
|
if (!data.projectId || !data.media)
|
||||||
|
return false
|
||||||
|
|
||||||
|
const uploadGroup = db
|
||||||
|
.prepare(`
|
||||||
|
select id, telegram_id, project_id, is_channel, access_hash
|
||||||
|
from groups
|
||||||
|
where id = (select upload_group_id
|
||||||
|
from customers
|
||||||
|
where id = (select customer_id from projects where id = :project_id limit 1)
|
||||||
|
limit 1)
|
||||||
|
limit 1
|
||||||
|
`)
|
||||||
|
.safeIntegers(true)
|
||||||
|
.get({project_id: data.projectId})
|
||||||
|
|
||||||
|
if (!uploadGroup || !uploadGroup.telegram_id || uploadGroup.id == data.originGroupId)
|
||||||
|
return false
|
||||||
|
|
||||||
|
const tgUploadGroupId = uploadGroup.telegram_id
|
||||||
|
|
||||||
|
const peer = uploadGroup.is_channel ?
|
||||||
|
new Api.PeerChannel({ channelId: tgUploadGroupId }) :
|
||||||
|
new Api.PeerChat({ chatlId: tgUploadGroupId })
|
||||||
|
|
||||||
|
let resultId = 0
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await client.invoke(new Api.messages.SendMedia({
|
||||||
|
peer,
|
||||||
|
media: data.media,
|
||||||
|
message: data.caption || '',
|
||||||
|
background: true,
|
||||||
|
silent: true
|
||||||
|
}))
|
||||||
|
|
||||||
|
const update = result.updates.find(u =>
|
||||||
|
(u.className == 'UpdateNewMessage' || u.className == 'UpdateNewChannelMessage') &&
|
||||||
|
u.message.className == 'Message' &&
|
||||||
|
(u.message.peerId.channelId?.value == tgUploadGroupId || u.message.peerId.chatId?.value == tgUploadGroupId) &&
|
||||||
|
u.message.media)
|
||||||
|
|
||||||
|
const udoc = update?.message?.media?.document
|
||||||
|
if (udoc) {
|
||||||
|
resultId = db
|
||||||
|
.prepare(`
|
||||||
|
insert into documents (project_id, origin_group_id, origin_message_id, group_id, message_id,
|
||||||
|
file_id, access_hash, filename, mime, caption, size, published_by, parent_type, parent_id)
|
||||||
|
values (:project_id, :origin_group_id, :origin_message_id, :group_id, :message_id,
|
||||||
|
:file_id, :access_hash, :filename, :mime, :caption, :size, :published_by, :parent_type, :parent_id)
|
||||||
|
returning id
|
||||||
|
`)
|
||||||
|
.safeIntegers(true)
|
||||||
|
.pluck(true)
|
||||||
|
.get({
|
||||||
|
project_id: data.projectId,
|
||||||
|
origin_group_id: data.originGroupId,
|
||||||
|
origin_message_id: data.originMessageId,
|
||||||
|
group_id: uploadGroup.id,
|
||||||
|
message_id: update.message.id,
|
||||||
|
file_id: udoc.id.value,
|
||||||
|
filename: udoc.attributes.find(attr => attr.className == 'DocumentAttributeFilename')?.fileName,
|
||||||
|
access_hash: udoc.accessHash.value,
|
||||||
|
mime: udoc.mimeType,
|
||||||
|
caption: data.caption,
|
||||||
|
size: udoc.size.value,
|
||||||
|
published_by: data.publishedBy,
|
||||||
|
parent_type: data.parentType,
|
||||||
|
parent_id: data.parentId
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
fs.appendFileSync('./1.log', '\n\nERR:' + err.message + ':' + JSON.stringify (err.stack)+'\n\n')
|
||||||
|
console.error('Message.registerUpload: ' + err.message)
|
||||||
|
}
|
||||||
|
|
||||||
|
return resultId
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onNewServiceMessage (msg, isChannel) {
|
||||||
|
const action = msg.action || {}
|
||||||
|
const tgGroupId = isChannel ? msg.peerId?.channelId?.value : msg.peerId?.chatId?.value
|
||||||
|
const groupId = await registerGroup(tgGroupId, isChannel)
|
||||||
|
|
||||||
|
// Group/Channel rename
|
||||||
|
if (action.className == 'MessageActionChatEditTitle') {
|
||||||
|
const info = db
|
||||||
|
.prepare(`
|
||||||
|
update groups
|
||||||
|
set name = :name, is_channel = :is_channel, last_update_time = :last_update_time
|
||||||
|
where telegram_id = :telegram_id
|
||||||
|
`)
|
||||||
|
.safeIntegers(true)
|
||||||
|
.run({
|
||||||
|
name: action.title,
|
||||||
|
is_channel: +isChannel,
|
||||||
|
last_update_time: Math.floor (Date.now() / 1000),
|
||||||
|
telegram_id: tgGroupId
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Chat to Channel
|
||||||
|
if (action.className == 'MessageActionChatMigrateTo') {
|
||||||
|
const info = db
|
||||||
|
.prepare(`
|
||||||
|
update groups
|
||||||
|
set telegram_id = :new_telegram_id, name = :name, is_channel = 1, last_update_time = :last_update_time
|
||||||
|
where telegram_id = :old_telegram_id
|
||||||
|
`)
|
||||||
|
.safeIntegers(true)
|
||||||
|
.run({
|
||||||
|
name: action.title,
|
||||||
|
last_update_time: Date.now() / 1000,
|
||||||
|
old_telegram_id: tgGroupId,
|
||||||
|
new_telegram_id: action.channelId.value
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// User/s un/register
|
||||||
|
if (action.className == 'MessageActionChatAddUser' || action.className == 'MessageActionChatDeleteUser' ||
|
||||||
|
action.className == 'MessageActionChannelAddUser' || action.className == 'MessageActionChannelDeleteUser'
|
||||||
|
) {
|
||||||
|
|
||||||
|
const tgUserIds = [action.user, action.users, action.userId].flat().filter(Boolean).map(e => BigInt(e.value))
|
||||||
|
const isAdd = action.className == 'MessageActionChatAddUser' || action.className == 'MessageActionChannelAddUser'
|
||||||
|
|
||||||
|
if (tgUserIds.indexOf(bot.id) == -1) {
|
||||||
|
// Add/remove non-bot users
|
||||||
|
for (const tgUserId of tgUserIds) {
|
||||||
|
const userId = registerUser(tgUserId)
|
||||||
|
|
||||||
|
if (isAdd) {
|
||||||
|
try {
|
||||||
|
const user = await client.getEntity(new Api.PeerUser({ userId: tgUserId }))
|
||||||
|
updateUser(userId, user)
|
||||||
|
await updateUserPhoto (userId, user)
|
||||||
|
} catch (err) {
|
||||||
|
console.error(msg.className + ', ' + userId + ': ' + err.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const query = isAdd ?
|
||||||
|
`insert or ignore into group_users (group_id, user_id) values (:group_id, :user_id)` :
|
||||||
|
`delete from group_users where group_id = :group_id and user_id = :user_id`
|
||||||
|
db.prepare(query).run({ group_id: groupId, user_id: userId })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onNewMessage (msg, isChannel) {
|
||||||
|
const tgGroupId = isChannel ? msg.peerId?.channelId?.value : msg.peerId?.chatId?.value
|
||||||
|
const groupId = await registerGroup(tgGroupId, isChannel)
|
||||||
|
|
||||||
|
// Document is detected
|
||||||
|
if (msg.media?.document) {
|
||||||
|
const doc = msg.media.document
|
||||||
|
|
||||||
|
const projectId = db
|
||||||
|
.prepare(`select project_id from groups where telegram_id = :telegram_id`)
|
||||||
|
.safeIntegers(true)
|
||||||
|
.pluck(true)
|
||||||
|
.get({telegram_id: tgGroupId})
|
||||||
|
|
||||||
|
const media = new Api.InputMediaDocument({
|
||||||
|
id: new Api.InputDocument({
|
||||||
|
id: doc.id.value,
|
||||||
|
accessHash: doc.accessHash.value,
|
||||||
|
fileReference: doc.fileReference
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
await registerUpload({
|
||||||
|
projectId,
|
||||||
|
media,
|
||||||
|
caption: msg.message,
|
||||||
|
originGroupId: groupId,
|
||||||
|
originMessageId: msg.id,
|
||||||
|
parentType: 0,
|
||||||
|
publishedBy: registerUser (msg.fromId?.userId?.value)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (msg.message?.startsWith('KEY-')) {
|
||||||
|
let projectName = db
|
||||||
|
.prepare(`
|
||||||
|
select name
|
||||||
|
from projects
|
||||||
|
where id in (
|
||||||
|
select project_id from groups where id = :group_id
|
||||||
|
union
|
||||||
|
select id from projects where upload_group_id = :group_id)
|
||||||
|
`)
|
||||||
|
.pluck(true)
|
||||||
|
.get({ group_id: groupId })
|
||||||
|
|
||||||
|
if (projectName)
|
||||||
|
return await bot.sendMessage(groupId, 'Группа уже используется на проекте ' + projectName)
|
||||||
|
|
||||||
|
const [_, time64, key] = msg.message.substr(3).split('-')
|
||||||
|
const now = Math.floor(Date.now() / 1000)
|
||||||
|
const time = Buffer.from(time64, 'base64')
|
||||||
|
|
||||||
|
if (now - 3600 >= time && time >= now)
|
||||||
|
return await bot.sendMessage(groupId, 'Время действия ключа для привязки истекло')
|
||||||
|
|
||||||
|
const projectId = db
|
||||||
|
.prepare(`select id from projects where generate_key(id, :time) = :key`)
|
||||||
|
.pluck(true)
|
||||||
|
.get({ key: msg.message.trim(), time })
|
||||||
|
|
||||||
|
if (projectId) {
|
||||||
|
await attachGroup(groupId, isChannel, projectId)
|
||||||
|
await onGroupAttach(tgGroupId, isChannel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (msg.message?.startsWith('/start')) {
|
||||||
|
// Called by https://t.me/ready_or_not_2025_bot?startgroup=<customer_id/project_id>
|
||||||
|
if (/start@ready_or_not_2025_bot (-|)([\d]+)$/g.test(msg.message)) {
|
||||||
|
const tgUserId = msg.fromId?.userId?.value
|
||||||
|
const param = +msg.message.split(' ')[1]
|
||||||
|
|
||||||
|
// Set upload group for customer
|
||||||
|
if (param < 0) {
|
||||||
|
const customerId = -param
|
||||||
|
|
||||||
|
db
|
||||||
|
.prepare(`
|
||||||
|
update customers
|
||||||
|
set upload_group_id = :group_id
|
||||||
|
where id = :customer_id and telegram_user_id = :telegram_user_id
|
||||||
|
`)
|
||||||
|
.safeIntegers(true)
|
||||||
|
.run({
|
||||||
|
group_id: groupId,
|
||||||
|
customer_id: customerId,
|
||||||
|
telegram_user_id: tgUserId
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add group to project
|
||||||
|
if (param > 0) {
|
||||||
|
const projectId = param
|
||||||
|
|
||||||
|
const customerId = db
|
||||||
|
.prepare(`select customer_id from projects where id = :project_id`)
|
||||||
|
.pluck(true)
|
||||||
|
.get({project_id: projectId})
|
||||||
|
|
||||||
|
db
|
||||||
|
.prepare(`
|
||||||
|
update groups
|
||||||
|
set project_id = :project_id
|
||||||
|
where id = :group_id and exists(
|
||||||
|
select 1
|
||||||
|
from customers
|
||||||
|
where id = :customer_id and telegram_user_id = :telegram_user_id)
|
||||||
|
`)
|
||||||
|
.safeIntegers(true)
|
||||||
|
.run({
|
||||||
|
project_id: projectId,
|
||||||
|
group_id: groupId,
|
||||||
|
customer_id: customerId,
|
||||||
|
telegram_user_id: tgUserId
|
||||||
|
})
|
||||||
|
|
||||||
|
await reloadGroupUsers(groupId, false)
|
||||||
|
await onGroupAttach(tgGroupId, isChannel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onNewUserMessage (msg) {
|
||||||
|
if (msg.message == '/start' && msg.peerId?.className == 'PeerUser') {
|
||||||
|
const tgUserId = msg.peerId?.userId?.value
|
||||||
|
const userId = registerUser(tgUserId)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const user = await client.getEntity(new Api.PeerUser({ userId: tgUserId }))
|
||||||
|
updateUser(userId, user)
|
||||||
|
await updateUserPhoto (userId, user)
|
||||||
|
|
||||||
|
const inputPeer = new Api.InputPeerUser({userId: tgUserId, accessHash: user.accessHash.value})
|
||||||
|
const resultBtn = await client.sendMessage(inputPeer, {
|
||||||
|
message: 'Сообщение от бота',
|
||||||
|
buttons: client.buildReplyMarkup([
|
||||||
|
[Button.url('Админка', 'https://t.me/ready_or_not_2025_bot/userapp?startapp=admin')],
|
||||||
|
[Button.url('Пользователь', 'https://t.me/ready_or_not_2025_bot/userapp?startapp=user')]
|
||||||
|
])
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
console.error(msg.className + ', ' + userId + ': ' + err.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onUpdatePaticipant (update, isChannel) {
|
||||||
|
const tgGroupId = isChannel ? update.channelId?.value : update.chatlId?.value
|
||||||
|
if (!tgGroupId || update.userId?.value != bot.id)
|
||||||
|
return
|
||||||
|
|
||||||
|
const groupId = await registerGroup (tgGroupId, isChannel)
|
||||||
|
|
||||||
|
const isBan = update.prevParticipant && !update.newParticipant
|
||||||
|
const isAdd = (!update.prevParticipant || update.prevParticipant?.className == 'ChannelParticipantBanned') && update.newParticipant
|
||||||
|
|
||||||
|
if (isBan || isAdd)
|
||||||
|
await reloadGroupUsers(groupId, isBan)
|
||||||
|
|
||||||
|
if (isBan) {
|
||||||
|
db
|
||||||
|
.prepare(`update groups set project_id = null where id = :group_id`)
|
||||||
|
.run({group_id: groupId})
|
||||||
|
}
|
||||||
|
|
||||||
|
const botCanBan = update.newParticipant?.adminRights?.banUsers || 0
|
||||||
|
db
|
||||||
|
.prepare(`update groups set bot_can_ban = :bot_can_ban where id = :group_id`)
|
||||||
|
.run({group_id: groupId, bot_can_ban: +botCanBan})
|
||||||
|
}
|
||||||
|
|
||||||
|
class Bot extends EventEmitter {
|
||||||
|
|
||||||
|
async start (apiId, apiHash, botAuthToken) {
|
||||||
|
this.id = 7236504417n
|
||||||
|
|
||||||
|
client = new TelegramClient(session, apiId, apiHash, {})
|
||||||
|
|
||||||
|
client.addEventHandler(async (update) => {
|
||||||
|
if (update.className == 'UpdateConnectionState')
|
||||||
|
return
|
||||||
|
|
||||||
|
if (update.className == 'UpdateNewMessage' || update.className == 'UpdateNewChannelMessage') {
|
||||||
|
const msg = update?.message
|
||||||
|
const isChannel = update.className == 'UpdateNewChannelMessage'
|
||||||
|
|
||||||
|
if (!msg)
|
||||||
|
return
|
||||||
|
|
||||||
|
const result = msg.peerId?.className == 'PeerUser' ? await onNewUserMessage(msg) :
|
||||||
|
msg.className == 'MessageService' ? await onNewServiceMessage(msg, isChannel) :
|
||||||
|
await onNewMessage(msg, isChannel)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (update.className == 'UpdateChatParticipant' || update.className == 'UpdateChannelParticipant')
|
||||||
|
await onUpdatePaticipant(update, update.className == 'UpdateChannelParticipant')
|
||||||
|
})
|
||||||
|
|
||||||
|
await client.start({botAuthToken})
|
||||||
|
}
|
||||||
|
|
||||||
|
async uploadDocument(projectId, fileName, mime, data, parentType, parentId, publishedBy) {
|
||||||
|
const file = await client.uploadFile({ file: new CustomFile(fileName, data.length, '', data), workers: 1 })
|
||||||
|
|
||||||
|
const media = new Api.InputMediaUploadedDocument({
|
||||||
|
file,
|
||||||
|
mimeType: mime,
|
||||||
|
attributes: [new Api.DocumentAttributeFilename({ fileName })]
|
||||||
|
})
|
||||||
|
|
||||||
|
return await registerUpload({
|
||||||
|
projectId,
|
||||||
|
media,
|
||||||
|
parentType,
|
||||||
|
parentId,
|
||||||
|
publishedBy
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async downloadDocument(projectId, documentId) {
|
||||||
|
const document = db
|
||||||
|
.prepare(`
|
||||||
|
select file_id, access_hash, '' thumbSize, filename, mime
|
||||||
|
from documents where id = :document_id and project_id = :project_id
|
||||||
|
`)
|
||||||
|
.safeIntegers(true)
|
||||||
|
.get({project_id: projectId, document_id: documentId})
|
||||||
|
|
||||||
|
if (!document)
|
||||||
|
return false
|
||||||
|
|
||||||
|
const result = await client.downloadFile(new Api.InputDocumentFileLocation({
|
||||||
|
id: document.file_id,
|
||||||
|
accessHash: document.access_hash,
|
||||||
|
fileReference: Buffer.from(document.filename),
|
||||||
|
thumbSize: ''
|
||||||
|
}, {}))
|
||||||
|
|
||||||
|
return {
|
||||||
|
filename: document.filename,
|
||||||
|
mime: document.mime,
|
||||||
|
size: result.length,
|
||||||
|
data: result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async reloadGroupUsers(groupId, onlyReset) {
|
||||||
|
return reloadGroupUsers(groupId, onlyReset)
|
||||||
|
}
|
||||||
|
|
||||||
|
async sendMessage (groupId, message) {
|
||||||
|
const group = db
|
||||||
|
.prepare(`select telegram_id, is_channel from groups where id = :group_id`)
|
||||||
|
.get({ group_id: groupId})
|
||||||
|
|
||||||
|
if (!group)
|
||||||
|
return
|
||||||
|
|
||||||
|
const entity = group.is_channel ? { channelId: group.telegram_id } : { chatId: group.telegram_id }
|
||||||
|
const inputPeer = await client.getEntity( group.is_channel ?
|
||||||
|
new Api.InputPeerChannel(entity) :
|
||||||
|
new Api.InputPeerChat(entity)
|
||||||
|
)
|
||||||
|
|
||||||
|
await client.sendMessage(inputPeer, {message})
|
||||||
|
|
||||||
|
const delay = ms => new Promise(resolve => setTimeout(resolve, ms))
|
||||||
|
await delay(1000)
|
||||||
|
}
|
||||||
|
|
||||||
|
async leaveGroup (groupId) {
|
||||||
|
const group = db
|
||||||
|
.prepare(`select telegram_id, is_channel from groups where id = :group_id`)
|
||||||
|
.get({ group_id: groupId})
|
||||||
|
|
||||||
|
if (!group)
|
||||||
|
return
|
||||||
|
|
||||||
|
if (group.is_channel) {
|
||||||
|
const inputPeer = await client.getEntity(new Api.InputPeerChannel({ channelId: group.telegram_id }))
|
||||||
|
await client.invoke(new Api.channels.LeaveChannel({ channel: inputPeer }))
|
||||||
|
} else {
|
||||||
|
await client.invoke(new Api.messages.DeleteChatUser({ chatId: group.telegram_id, userId: this.id }))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const bot = new Bot()
|
||||||
|
|
||||||
|
module.exports = bot
|
||||||
|
|
||||||
643
backend/apps/miniapp.js
Normal file
643
backend/apps/miniapp.js
Normal file
@@ -0,0 +1,643 @@
|
|||||||
|
const express = require('express')
|
||||||
|
const multer = require('multer')
|
||||||
|
const crypto = require('crypto')
|
||||||
|
const fs = require('fs')
|
||||||
|
const contentDisposition = require('content-disposition')
|
||||||
|
|
||||||
|
const bot = require('./bot')
|
||||||
|
const db = require('../include/db')
|
||||||
|
|
||||||
|
const app = express.Router()
|
||||||
|
const upload = multer({
|
||||||
|
storage: multer.memoryStorage(),
|
||||||
|
limits: {
|
||||||
|
fileSize: 10_000_000 // 10mb
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
function hasAccess(project_id, user_id) {
|
||||||
|
return !!db
|
||||||
|
.prepare(`
|
||||||
|
select 1
|
||||||
|
from group_users
|
||||||
|
where user_id = :user_id and
|
||||||
|
group_id in (select id from groups where project_id = :project_id) and
|
||||||
|
not exists(select 1 from user_details where user_id = :user_id and project_id = :project_id and is_blocked = 1) and
|
||||||
|
not exists(select 1 from projects where id = :project_id and is_deleted = 1)
|
||||||
|
`)
|
||||||
|
.get({project_id, user_id})
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessions = {}
|
||||||
|
|
||||||
|
app.use((req, res, next) => {
|
||||||
|
if (req.path == '/user/login')
|
||||||
|
return next()
|
||||||
|
|
||||||
|
const sid = req.query.sid || req.cookies.sid
|
||||||
|
req.session = sessions[sid]
|
||||||
|
if (!req.session)
|
||||||
|
throw Error('ACCESS_DENIED::401')
|
||||||
|
|
||||||
|
res.locals.user_id = req.session.user_id
|
||||||
|
next()
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
app.post('/user/login', (req, res, next) => {
|
||||||
|
db
|
||||||
|
.prepare(`insert or ignore into users (telegram_id) values (:telegram_id)`)
|
||||||
|
.safeIntegers(true)
|
||||||
|
.run(res.locals)
|
||||||
|
|
||||||
|
const user_id = db
|
||||||
|
.prepare(`select id from users where telegram_id = :telegram_id`)
|
||||||
|
.safeIntegers(true)
|
||||||
|
.pluck(true)
|
||||||
|
.get(res.locals)
|
||||||
|
|
||||||
|
const sid = crypto.randomBytes(64).toString('hex')
|
||||||
|
req.session = sessions[sid] = {sid, user_id}
|
||||||
|
res.setHeader('Set-Cookie', [`sid=${sid};httpOnly;path=/`])
|
||||||
|
res.locals.user_id = user_id
|
||||||
|
|
||||||
|
res.status(200).json({success: true})
|
||||||
|
})
|
||||||
|
|
||||||
|
app.get('/project', (req, res, next) => {
|
||||||
|
const where = req.query.id ? ' and p.id = ' + parseInt(req.query.id) : ''
|
||||||
|
|
||||||
|
const rows = db
|
||||||
|
.prepare(`
|
||||||
|
select p.id, p.name, p.description, p.logo,
|
||||||
|
c.name customer_name, c.upload_group_id <> 0 has_upload
|
||||||
|
from projects p
|
||||||
|
inner join customers c on p.customer_id = c.id
|
||||||
|
where p.id in (
|
||||||
|
select project_id
|
||||||
|
from groups
|
||||||
|
where id in (select group_id from group_users where user_id = :user_id)
|
||||||
|
) and not exists(select 1 from user_details where user_id = :user_id and project_id = p.id and is_blocked = 1)
|
||||||
|
${where} and is_deleted <> 1
|
||||||
|
`)
|
||||||
|
.all(res.locals)
|
||||||
|
|
||||||
|
if (where && rows.length == 0)
|
||||||
|
throw Error('NOT_FOUND::404')
|
||||||
|
|
||||||
|
res.status(200).json({success: true, data: where ? rows[0] : rows})
|
||||||
|
})
|
||||||
|
|
||||||
|
app.get('/project/:pid(\\d+)', (req, res, next) => {
|
||||||
|
res.redirect(req.baseUrl + `/project?id=${req.params.pid}`)
|
||||||
|
})
|
||||||
|
|
||||||
|
app.use('/project/:pid(\\d+)/*', (req, res, next) => {
|
||||||
|
res.locals.project_id = parseInt(req.params.pid)
|
||||||
|
|
||||||
|
if (!hasAccess(res.locals.project_id, res.locals.user_id))
|
||||||
|
throw Error('ACCESS_DENIED::401')
|
||||||
|
|
||||||
|
const row = db
|
||||||
|
.prepare('select customer_id from projects where id = :project_id')
|
||||||
|
.get(res.locals)
|
||||||
|
|
||||||
|
res.locals.customer_id = row.customer_id
|
||||||
|
|
||||||
|
next()
|
||||||
|
})
|
||||||
|
|
||||||
|
app.get('/project/:pid(\\d+)/user', (req, res, next) => {
|
||||||
|
const where = req.query.id ? ' and u.id = ' + parseInt(req.query.id) : ''
|
||||||
|
|
||||||
|
const users = db
|
||||||
|
.prepare(`
|
||||||
|
with actuals (user_id) as (
|
||||||
|
select distinct user_id
|
||||||
|
from group_users
|
||||||
|
where group_id in (select id from groups where project_id = :project_id)
|
||||||
|
and group_id in (select group_id from group_users where user_id = :user_id)
|
||||||
|
),
|
||||||
|
contributors (user_id) as (
|
||||||
|
select created_by from tasks where project_id = :project_id
|
||||||
|
union
|
||||||
|
select assigned_to from tasks where project_id = :project_id
|
||||||
|
union
|
||||||
|
select created_by from meetings where project_id = :project_id
|
||||||
|
union
|
||||||
|
select published_by from documents where project_id = :project_id
|
||||||
|
),
|
||||||
|
members (user_id, is_leave) as (
|
||||||
|
select user_id, 0 is_leave from actuals
|
||||||
|
union all
|
||||||
|
select user_id, 1 is_leave from contributors where user_id not in (select user_id from actuals)
|
||||||
|
)
|
||||||
|
select u.id,
|
||||||
|
u.telegram_id,
|
||||||
|
u.username,
|
||||||
|
u.firstname,
|
||||||
|
u.lastname,
|
||||||
|
u.photo,
|
||||||
|
u.json_phone_projects,
|
||||||
|
ud.fullname,
|
||||||
|
ud.role,
|
||||||
|
ud.department,
|
||||||
|
ud.is_blocked,
|
||||||
|
(select company_id
|
||||||
|
from company_users
|
||||||
|
where user_id = u.id and
|
||||||
|
company_id in (select id from companies where project_id = :project_id)) company_id,
|
||||||
|
m.is_leave
|
||||||
|
from users u
|
||||||
|
inner join members m on u.id = m.user_id
|
||||||
|
left join user_details ud on ud.user_id = u.id and ud.project_id = :project_id
|
||||||
|
where 1 = 1 ${where}
|
||||||
|
`)
|
||||||
|
.all(res.locals)
|
||||||
|
|
||||||
|
const companies = db
|
||||||
|
.prepare('select id, name, email, phone, site, description from companies where project_id = :project_id')
|
||||||
|
.all(res.locals)
|
||||||
|
.reduce((companies, row) => {
|
||||||
|
companies[row.id] = row
|
||||||
|
return companies
|
||||||
|
}, {})
|
||||||
|
|
||||||
|
const mappings = {}
|
||||||
|
const company_id = users.find(m => m.id == res.locals.user_id).company_id
|
||||||
|
if (company_id) {
|
||||||
|
res.locals.company_id = company_id
|
||||||
|
|
||||||
|
db
|
||||||
|
.prepare('select show_as_id, show_to_id from company_mappings where project_id = :project_id and company_id = :company_id')
|
||||||
|
.all(res.locals)
|
||||||
|
.forEach(row => mappings[row.show_to_id] = row.show_to_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
users.forEach(m => {
|
||||||
|
m.company = companies[mappings[m.company_id] || m.company_id]
|
||||||
|
delete m.company_id
|
||||||
|
})
|
||||||
|
|
||||||
|
users.forEach(m => {
|
||||||
|
const isHide = JSON.parse(m.json_phone_projects || []).indexOf(res.locals.project_id) == -1
|
||||||
|
if (isHide)
|
||||||
|
delete m.phone
|
||||||
|
delete m.json_phone_projects
|
||||||
|
})
|
||||||
|
|
||||||
|
if (where && users.length == 0)
|
||||||
|
throw Error('NOT_FOUND::404')
|
||||||
|
|
||||||
|
res.status(200).json({success: true, data: where ? users[0] : users})
|
||||||
|
})
|
||||||
|
|
||||||
|
app.get('/project/:pid(\\d+)/user/reload', async (req, res, next) => {
|
||||||
|
const groupIds = db
|
||||||
|
.prepare(`select id from groups where project_id = :project_id`)
|
||||||
|
.all(res.locals)
|
||||||
|
.map(e => e.id)
|
||||||
|
|
||||||
|
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms))
|
||||||
|
|
||||||
|
for (const groupId of groupIds) {
|
||||||
|
await bot.reloadGroupUsers(groupId)
|
||||||
|
await sleep(1000)
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(200).json({success: true})
|
||||||
|
})
|
||||||
|
|
||||||
|
app.get('/project/:pid(\\d+)/group', (req, res, next) => {
|
||||||
|
const where = req.query.id ? ' and id = ' + parseInt(req.query.id) : ''
|
||||||
|
|
||||||
|
const rows = db
|
||||||
|
.prepare(`
|
||||||
|
select id, name, telegram_id
|
||||||
|
from groups
|
||||||
|
where project_id = :project_id and id in (select group_id from group_users where user_id = :user_id)
|
||||||
|
${where}
|
||||||
|
`)
|
||||||
|
.all(res.locals)
|
||||||
|
|
||||||
|
if (where && rows.length == 0)
|
||||||
|
throw Error('NOT_FOUND::404')
|
||||||
|
|
||||||
|
res.status(200).json({success: true, data: where ? rows[0] : rows})
|
||||||
|
})
|
||||||
|
|
||||||
|
app.get('/project/:pid(\\d+)/group/:gid(\\d+)', (req, res, next) => {
|
||||||
|
res.redirect(req.baseUrl + `/project/${req.params.pid}/group?id=${req.params.gid}`)
|
||||||
|
})
|
||||||
|
|
||||||
|
// TASK
|
||||||
|
app.get('/project/:pid(\\d+)/task', (req, res, next) => {
|
||||||
|
const where = req.query.id ? ' and t.id = ' + parseInt(req.query.id) : ''
|
||||||
|
|
||||||
|
const rows = db
|
||||||
|
.prepare(`
|
||||||
|
select id, name, created_by, assigned_to, priority, status, time_spent, create_date, plan_date, close_date,
|
||||||
|
(select json_group_array(user_id) from task_users where task_id = t.id) observers,
|
||||||
|
(select json_group_array(id) from documents where parent_type = 1 and parent_id = t.id) attachments
|
||||||
|
from tasks t
|
||||||
|
where project_id = :project_id and
|
||||||
|
(created_by = :user_id or assigned_to = :user_id or exists(select 1 from task_users where task_id = t.id and user_id = :user_id))
|
||||||
|
${where}
|
||||||
|
`)
|
||||||
|
.all(res.locals)
|
||||||
|
|
||||||
|
rows.forEach(row => {
|
||||||
|
row.observers = JSON.parse(row.observers)
|
||||||
|
row.attachments = JSON.parse(row.attachments)
|
||||||
|
})
|
||||||
|
|
||||||
|
if (where && rows.length == 0)
|
||||||
|
throw Error('NOT_FOUND::404')
|
||||||
|
|
||||||
|
res.status(200).json({success: true, data: where ? rows[0] : rows})
|
||||||
|
})
|
||||||
|
|
||||||
|
app.get('/project/:pid(\\d+)/task/:tid(\\d+)', (req, res, next) => {
|
||||||
|
res.redirect(req.baseUrl + `/project/${req.params.pid}/task?id=${req.params.tid}`)
|
||||||
|
})
|
||||||
|
|
||||||
|
app.post('/project/:pid(\\d+)/task', (req, res, next) => {
|
||||||
|
res.locals.name = req.body?.name
|
||||||
|
res.locals.status = parseInt(req.body?.status)
|
||||||
|
res.locals.priority = parseInt(req.body?.priority)
|
||||||
|
res.locals.assigned_to = req.body?.assigned_to ? parseInt(req.body?.assigned_to) : undefined
|
||||||
|
res.locals.create_date = Math.floor(Date.now() / 1000)
|
||||||
|
res.locals.plan_date = req.body?.plan_date ? parseInt(req.body?.plan_date) : undefined
|
||||||
|
|
||||||
|
if (res.locals.assigned_to && !hasAccess(res.locals.project_id, res.locals.assigned_to))
|
||||||
|
throw Error('INCORRECT_ASSIGNED_TO::400')
|
||||||
|
|
||||||
|
const id = db
|
||||||
|
.prepare(`
|
||||||
|
insert into tasks (project_id, name, created_by, assigned_to, priority, status, create_date, plan_date)
|
||||||
|
values (:project_id, :name, :user_id, :assigned_to, :priority, :status, :create_date, :plan_date)
|
||||||
|
returning id
|
||||||
|
`)
|
||||||
|
.pluck(true)
|
||||||
|
.get(res.locals)
|
||||||
|
|
||||||
|
res.status(200).json({success: true, data: id})
|
||||||
|
})
|
||||||
|
|
||||||
|
app.use('/project/:pid(\\d+)/task/:tid(\\d+)*', (req, res, next) => {
|
||||||
|
res.locals.task_id = req.params.tid
|
||||||
|
|
||||||
|
const task = db
|
||||||
|
.prepare(`
|
||||||
|
select created_by, assigned_to
|
||||||
|
from tasks
|
||||||
|
where id = :task_id and project_id = :project_id
|
||||||
|
and (created_by = :user_id or assigned_to = :user_id or exists(select 1 from task_users where task_id = :task_id and user_id = :user_id))
|
||||||
|
`)
|
||||||
|
.get(res.locals)
|
||||||
|
|
||||||
|
if (!task)
|
||||||
|
throw Error('NOT_FOUND::404')
|
||||||
|
|
||||||
|
res.locals.is_author = task.created_by == res.locals.user_id
|
||||||
|
res.locals.is_assigned = task.assigned_to == res.locals.user_id
|
||||||
|
|
||||||
|
next()
|
||||||
|
})
|
||||||
|
|
||||||
|
app.put('/project/:pid(\\d+)/task/:tid(\\d+)', (req, res, next) => {
|
||||||
|
if (!res.locals.is_author && !res.locals.is_assigned)
|
||||||
|
throw Error('ACCESS_DENIED::401')
|
||||||
|
|
||||||
|
res.locals.id = res.locals.task_id
|
||||||
|
res.locals.name = req.body?.name
|
||||||
|
res.locals.status = parseInt(req.body?.status)
|
||||||
|
res.locals.priority = parseInt(req.body?.priority)
|
||||||
|
res.locals.assigned_to = req.body?.assigned_to ? parseInt(req.body?.assigned_to) : undefined
|
||||||
|
res.locals.plan_date = req.body?.plan_date ? parseInt(req.body?.plan_date) : undefined
|
||||||
|
|
||||||
|
const columns = res.locals.is_author ? ['name', 'assigned_to', 'priority', 'status', 'plan_date', 'time_spent'] : ['status', 'time_spent']
|
||||||
|
const info = db
|
||||||
|
.prepareUpdate('tasks', columns, res.locals, ['id', 'project_id'])
|
||||||
|
.run(res.locals)
|
||||||
|
|
||||||
|
if (info.changes == 0)
|
||||||
|
throw Error('NOT_FOUND::404')
|
||||||
|
|
||||||
|
res.status(200).json({success: true})
|
||||||
|
})
|
||||||
|
|
||||||
|
app.delete('/project/:pid(\\d+)/task/:tid(\\d+)', (req, res, next) => {
|
||||||
|
if (!res.locals.is_author)
|
||||||
|
throw Error('ACCESS_DENIED::401')
|
||||||
|
|
||||||
|
const info = db
|
||||||
|
.prepare(`delete from tasks where id = :task_id and project_id = :project_id and created_by = :user_id`)
|
||||||
|
.run(res.locals)
|
||||||
|
|
||||||
|
if (info.changes == 0)
|
||||||
|
throw Error('NOT_FOUND::404')
|
||||||
|
|
||||||
|
res.status(200).json({success: true})
|
||||||
|
})
|
||||||
|
|
||||||
|
app.put('/project/:pid(\\d+)/task/:tid(\\d+)/observer', (req, res, next) => {
|
||||||
|
if (!res.locals.is_author && !res.locals.is_assigned)
|
||||||
|
throw Error('ACCESS_DENIED::401')
|
||||||
|
|
||||||
|
const user_ids = req.body instanceof Array ? [...new Set(req.body.map(e => parseInt(e)))] : []
|
||||||
|
|
||||||
|
// Проверка, что выбранные пользователи имеют доступ к проекту
|
||||||
|
let rows = db
|
||||||
|
.prepare(`
|
||||||
|
select user_id
|
||||||
|
from group_users
|
||||||
|
where group_id in (select id from groups where project_id = :project_id)
|
||||||
|
`)
|
||||||
|
.pluck(true)
|
||||||
|
.all(res.locals)
|
||||||
|
|
||||||
|
if (user_ids.some(user_id => !rows.contains(user_id)))
|
||||||
|
throw Error('INACCESSABLE_USER::400')
|
||||||
|
|
||||||
|
res.locals.json_ids = JSON.stringify(user_ids)
|
||||||
|
|
||||||
|
db
|
||||||
|
.prepare(`
|
||||||
|
delete from task_users where task_id = :task_id;
|
||||||
|
insert into task_users (task_id, user_id) select :task_id, value from json_each(:json_ids)
|
||||||
|
`)
|
||||||
|
.run(res.locals)
|
||||||
|
|
||||||
|
res.status(200).json({success: true})
|
||||||
|
})
|
||||||
|
|
||||||
|
// MEETINGS
|
||||||
|
app.get('/project/:pid(\\d+)/meeting', (req, res, next) => {
|
||||||
|
const where = req.query.id ? ' and m.id = ' + parseInt(req.query.id) : ''
|
||||||
|
|
||||||
|
const rows = db
|
||||||
|
.prepare(`
|
||||||
|
select id, name, description, created_by, meet_date,
|
||||||
|
(select json_group_array(user_id) from meeting_users where meeting_id = m.id) participants,
|
||||||
|
(select json_group_array(id) from documents where parent_type = 2 and parent_id = m.id) attachments
|
||||||
|
from meetings m
|
||||||
|
where project_id = :project_id and
|
||||||
|
(created_by = :user_id or exists(select 1 from meeting_users where meeting_id = m.id and user_id = :user_id))
|
||||||
|
${where}
|
||||||
|
`)
|
||||||
|
.all(res.locals)
|
||||||
|
|
||||||
|
rows.forEach(row => {
|
||||||
|
row.participants = JSON.parse(row.participants)
|
||||||
|
row.attachments = JSON.parse(row.attachments)
|
||||||
|
})
|
||||||
|
|
||||||
|
if (where && rows.length == 0)
|
||||||
|
throw Error('NOT_FOUND::404')
|
||||||
|
|
||||||
|
res.status(200).json({success: true, data: where ? rows[0] : rows})
|
||||||
|
})
|
||||||
|
|
||||||
|
app.get('/project/:pid(\\d+)/meeting/:mid(\\d+)', (req, res, next) => {
|
||||||
|
res.redirect(req.baseUrl + `/project/${req.params.pid}/meeting?id=${req.params.mid}`)
|
||||||
|
})
|
||||||
|
|
||||||
|
app.post('/project/:pid(\\d+)/meeting', (req, res, next) => {
|
||||||
|
res.locals.name = req.body?.name
|
||||||
|
res.locals.description = req.body?.description
|
||||||
|
res.locals.meet_date = req.body?.meet_date ? parseInt(req.body?.meet_date) : undefined
|
||||||
|
|
||||||
|
const id = db
|
||||||
|
.prepare(`
|
||||||
|
insert into meetings (project_id, name, description, created_by, meet_date)
|
||||||
|
values (:project_id, :name, :description, :user_id, :meet_date)
|
||||||
|
returning id
|
||||||
|
`)
|
||||||
|
.pluck(true)
|
||||||
|
.get(res.locals)
|
||||||
|
|
||||||
|
res.status(200).json({success: true, data: id})
|
||||||
|
})
|
||||||
|
|
||||||
|
app.use('/project/:pid(\\d+)/meeting/:mid(\\d+)*', (req, res, next) => {
|
||||||
|
res.locals.meeting_id = req.params.mid
|
||||||
|
|
||||||
|
const meeting = db
|
||||||
|
.prepare(`
|
||||||
|
select created_by
|
||||||
|
from meetings
|
||||||
|
where id = :meeting_id and project_id = :project_id
|
||||||
|
and (created_by = :user_id or exists(select 1 from meeting_users where meeting_id = :meeting_id and user_id = :user_id))
|
||||||
|
`)
|
||||||
|
.get(res.locals)
|
||||||
|
|
||||||
|
if (!meeting)
|
||||||
|
throw Error('NOT_FOUND::404')
|
||||||
|
|
||||||
|
res.locals.is_author = meeting.created_by == res.locals.user_id
|
||||||
|
|
||||||
|
next()
|
||||||
|
})
|
||||||
|
|
||||||
|
app.put('/project/:pid(\\d+)/meeting/:mid(\\d+)', (req, res, next) => {
|
||||||
|
if (!res.locals.is_author)
|
||||||
|
throw Error('ACCESS_DENIED::401')
|
||||||
|
|
||||||
|
res.locals.id = res.locals.meeting_id
|
||||||
|
res.locals.name = req.body?.name
|
||||||
|
res.locals.description = req.body?.description
|
||||||
|
res.locals.meet_date = req.body?.meet_date ? parseInt(req.body?.meet_date) : undefined
|
||||||
|
|
||||||
|
const info = db
|
||||||
|
.prepareUpdate('meetings', ['name', 'description', 'meet_date'], res.locals, ['id', 'project_id'])
|
||||||
|
.run(res.locals)
|
||||||
|
|
||||||
|
if (info.changes == 0)
|
||||||
|
throw Error('NOT_FOUND::404')
|
||||||
|
|
||||||
|
res.status(200).json({success: true})
|
||||||
|
})
|
||||||
|
|
||||||
|
app.delete('/project/:pid(\\d+)/meeting/:mid(\\d+)', (req, res, next) => {
|
||||||
|
if (!res.locals.is_author)
|
||||||
|
throw Error('ACCESS_DENIED::401')
|
||||||
|
|
||||||
|
const info = db
|
||||||
|
.prepare(`delete from meetings where id = :meeting_id and project_id = :project_id and created_by = :user_id`)
|
||||||
|
.run(res.locals)
|
||||||
|
|
||||||
|
if (info.changes == 0)
|
||||||
|
throw Error('NOT_FOUND::404')
|
||||||
|
|
||||||
|
res.status(200).json({success: true})
|
||||||
|
})
|
||||||
|
|
||||||
|
app.put('/project/:pid(\\d+)/meeting/:mid(\\d+)/participants', (req, res, next) => {
|
||||||
|
if (!res.locals.is_author)
|
||||||
|
throw Error('ACCESS_DENIED::401')
|
||||||
|
|
||||||
|
const user_ids = req.body instanceof Array ? [...new Set(req.body.map(e => parseInt(e)))] : []
|
||||||
|
|
||||||
|
// Проверка, что выбранные пользователи имеют доступ к проекту
|
||||||
|
let rows = db
|
||||||
|
.prepare(`
|
||||||
|
select user_id
|
||||||
|
from group_users
|
||||||
|
where group_id in (select id from groups where project_id = :project_id)
|
||||||
|
`)
|
||||||
|
.pluck(true) // .raw?
|
||||||
|
.all(res.locals)
|
||||||
|
|
||||||
|
if (user_ids.some(user_id => rows.indexOf(user_id)) == -1)
|
||||||
|
throw Error('INACCESSABLE_USER::400')
|
||||||
|
|
||||||
|
db
|
||||||
|
.prepare(`delete from meeting_users where meeting_id = :meeting_id`)
|
||||||
|
.run(res.locals)
|
||||||
|
|
||||||
|
res.locals.json_ids = JSON.stringify(user_ids)
|
||||||
|
db
|
||||||
|
.prepare(`insert into meeting_users (meeting_id, user_id) select :meeting_id, value from json_each(:json_ids)`)
|
||||||
|
.run(res.locals)
|
||||||
|
|
||||||
|
res.status(200).json({success: true})
|
||||||
|
})
|
||||||
|
|
||||||
|
// DOCUMENTS
|
||||||
|
app.get('/project/:pid(\\d+)/document', (req, res, next) => {
|
||||||
|
const ids = String(req.query.id).split(',').map(e => parseInt(e)).filter(e => e > 0)
|
||||||
|
const where = ids.length > 0 ? ' and id in (' + ids.join(', ') + ')' : ''
|
||||||
|
|
||||||
|
// Документы
|
||||||
|
// 1. Из групп, которые есть в проекте и в которых участвует пользователь
|
||||||
|
// 2. Из задач проекта, где пользователь автор, ответсвенный или наблюдатель
|
||||||
|
// 3. Из встреч на проекте, где пользователь создатель или участник
|
||||||
|
// To-Do: отдавать готовую ссылку --> как минимум GROUP_ID надо заменить на tgGroupId
|
||||||
|
const rows = db
|
||||||
|
.prepare(`
|
||||||
|
select id, origin_group_id, origin_message_id, filename, mime, caption, size, published_by, parent_id, parent_type
|
||||||
|
from documents d
|
||||||
|
where project_id = :project_id ${where} and (
|
||||||
|
origin_group_id in (select group_id from group_users where user_id = :user_id)
|
||||||
|
or
|
||||||
|
parent_type = 1 and parent_id in (
|
||||||
|
select id
|
||||||
|
from tasks t
|
||||||
|
where project_id = :project_id and (created_by = :user_id or assigned_to = :user_id or exists(select 1 from task_users where task_id = t.id and user_id = :user_id))
|
||||||
|
)
|
||||||
|
or
|
||||||
|
parent_type = 2 and parent_id in (
|
||||||
|
select id
|
||||||
|
from meetings m
|
||||||
|
where project_id = :project_id and (created_by = :user_id or exists(select 1 from meeting_users where meeting_id = m.id and user_id = :user_id))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
`)
|
||||||
|
.all(res.locals)
|
||||||
|
|
||||||
|
if (where && rows.length == 0)
|
||||||
|
throw Error('NOT_FOUND::404')
|
||||||
|
|
||||||
|
res.status(200).json({success: true, data: ids.length == 1 ? rows[0] : rows})
|
||||||
|
})
|
||||||
|
|
||||||
|
app.post('/project/:pid(\\d+)/:type(task|meeting)/:id(\\d+)/attach', upload.any(), async (req, res, next) => {
|
||||||
|
const parentType = req.params.type == 'task' ? 1 : 2
|
||||||
|
const parentId = req.params.id
|
||||||
|
|
||||||
|
const ids = []
|
||||||
|
for (const file of req.files) {
|
||||||
|
const id = await bot.uploadDocument(res.locals.project_id, file.originalname, file.mimetype, file.buffer, parentType, parentId, res.locals.user_id)
|
||||||
|
ids.push(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
res.redirect(req.baseUrl + `/project/${req.params.pid}/document?id=` + ids.join(','))
|
||||||
|
})
|
||||||
|
|
||||||
|
app.use('/project/:pid(\\d+)/document/:did(\\d+)', (req, res, next) => {
|
||||||
|
res.locals.document_id = req.params.did
|
||||||
|
|
||||||
|
const doc = db
|
||||||
|
.prepare(`select * from documents where id = :document_id and project_id = :project_id`)
|
||||||
|
.get(res.locals)
|
||||||
|
|
||||||
|
if (!doc)
|
||||||
|
throw Error('NOT_FOUND::404')
|
||||||
|
|
||||||
|
if (doc.parent_type == 0) {
|
||||||
|
res.locals.group_id = doc.group_id
|
||||||
|
const row = db
|
||||||
|
.prepare(`select 1 from group_users where group_id = :group_id and user_id = :user_id`)
|
||||||
|
.get(res.locals)
|
||||||
|
|
||||||
|
if (row) {
|
||||||
|
res.locals.can_download = true
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
res.locals.parent_id = doc.parent_id
|
||||||
|
const parent = doc.parent_type == 1 ? 'task' : 'meeting'
|
||||||
|
|
||||||
|
const row = db
|
||||||
|
.prepare(`
|
||||||
|
select 1
|
||||||
|
from ${parent}s
|
||||||
|
where id = :parent_id and project_id = :project_id or
|
||||||
|
exists(select 1 from ${parent}_users where ${parent}_id = :parent_id and user_id = :user_id)
|
||||||
|
`)
|
||||||
|
.get(res.locals)
|
||||||
|
|
||||||
|
if (row) {
|
||||||
|
res.locals.can_download = true
|
||||||
|
res.locals.can_delete = doc.published_by == res.locals.user_id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
next()
|
||||||
|
})
|
||||||
|
|
||||||
|
app.get('/project/:pid(\\d+)/document/:did(\\d+)', async (req, res, next) => {
|
||||||
|
if (!res.locals.can_download)
|
||||||
|
throw Error('NOT_FOUND::404')
|
||||||
|
|
||||||
|
const file = await bot.downloadDocument(res.locals.project_id, res.locals.document_id)
|
||||||
|
res.writeHead(200, {
|
||||||
|
'Content-Length': file.size,
|
||||||
|
'Content-Type': file.mime,
|
||||||
|
'Content-Disposition': contentDisposition(file.filename)
|
||||||
|
})
|
||||||
|
|
||||||
|
res.end(file.data)
|
||||||
|
})
|
||||||
|
|
||||||
|
app.delete('/project/:pid(\\d+)/document/:id(\\d+)', (req, res, next) => {
|
||||||
|
if (!res.locals.can_delete)
|
||||||
|
throw Error('NOT_FOUND::404')
|
||||||
|
|
||||||
|
const doc = db
|
||||||
|
.prepare(`delete from documents where id = :id and project_id = :project_id`)
|
||||||
|
.run(res.locals)
|
||||||
|
|
||||||
|
res.status(200).json({success: true})
|
||||||
|
})
|
||||||
|
|
||||||
|
app.get('/settings', (req, res, next) => {
|
||||||
|
const row = db
|
||||||
|
.prepare(`select coalesce(json_settings, '{}') from users where id = :user_id`)
|
||||||
|
.pluck(true)
|
||||||
|
.get(res.locals)
|
||||||
|
|
||||||
|
res.status(200).json({success: true, data: JSON.parse(row)})
|
||||||
|
})
|
||||||
|
|
||||||
|
app.put('/settings', (req, res, next) => {
|
||||||
|
res.locals.json_settings = JSON.stringify(req.body || {})
|
||||||
|
|
||||||
|
const row = db
|
||||||
|
.prepare(`update users set json_settings = :json_settings where id = :user_id`)
|
||||||
|
.run(res.locals)
|
||||||
|
|
||||||
|
res.status(200).json({success: true})
|
||||||
|
})
|
||||||
|
|
||||||
|
module.exports = app
|
||||||
BIN
backend/data/db.sqlite
Normal file
BIN
backend/data/db.sqlite
Normal file
Binary file not shown.
BIN
backend/data/db.sqlite-shm
Normal file
BIN
backend/data/db.sqlite-shm
Normal file
Binary file not shown.
BIN
backend/data/db.sqlite-wal
Normal file
BIN
backend/data/db.sqlite-wal
Normal file
Binary file not shown.
166
backend/data/init.sql
Normal file
166
backend/data/init.sql
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
pragma foreign_keys = off;
|
||||||
|
|
||||||
|
create table if not exists customers (
|
||||||
|
id integer primary key autoincrement,
|
||||||
|
name text check(name is null or trim(name) <> '' and length(name) < 256),
|
||||||
|
email text check(email is null or trim(email) <> '' and length(email) < 128),
|
||||||
|
password text check(password is null or length(password) > 7 and length(password) < 64),
|
||||||
|
telegram_user_id integer,
|
||||||
|
plan integer,
|
||||||
|
json_balance text default '{}',
|
||||||
|
activation_key text,
|
||||||
|
is_active integer default 0,
|
||||||
|
json_company text default '{}',
|
||||||
|
upload_group_id integer,
|
||||||
|
json_backup_server text default '{}',
|
||||||
|
json_backup_params text default '{}'
|
||||||
|
) strict;
|
||||||
|
|
||||||
|
create table if not exists projects (
|
||||||
|
id integer primary key autoincrement,
|
||||||
|
customer_id integer references customers(id) on delete cascade,
|
||||||
|
name text not null check(trim(name) <> '' and length(name) < 256),
|
||||||
|
description text check(description is null or length(description) < 4096),
|
||||||
|
logo text,
|
||||||
|
is_deleted integer default 0
|
||||||
|
) strict;
|
||||||
|
|
||||||
|
create table if not exists groups (
|
||||||
|
id integer primary key autoincrement,
|
||||||
|
project_id integer references projects(id) on delete cascade,
|
||||||
|
name text,
|
||||||
|
telegram_id integer,
|
||||||
|
access_hash integer,
|
||||||
|
is_channel integer check(is_channel in (0, 1)) default 0,
|
||||||
|
bot_can_ban integer default 0,
|
||||||
|
user_count integer,
|
||||||
|
last_update_time integer
|
||||||
|
);
|
||||||
|
create unique index if not exists idx_groups_telegram_id on groups (telegram_id);
|
||||||
|
|
||||||
|
create table if not exists users (
|
||||||
|
id integer primary key autoincrement,
|
||||||
|
telegram_id integer,
|
||||||
|
access_hash integer,
|
||||||
|
firstname text,
|
||||||
|
lastname text,
|
||||||
|
username text,
|
||||||
|
photo_id integer,
|
||||||
|
photo text,
|
||||||
|
language_code text,
|
||||||
|
phone text,
|
||||||
|
json_phone_projects text default '[]',
|
||||||
|
json_settings text default '{}'
|
||||||
|
) strict;
|
||||||
|
create unique index if not exists idx_users_telegram_id on users (telegram_id);
|
||||||
|
|
||||||
|
create table if not exists user_details (
|
||||||
|
user_id integer references users(id) on delete cascade,
|
||||||
|
project_id integer references projects(id) on delete cascade,
|
||||||
|
fullname text,
|
||||||
|
role text,
|
||||||
|
department text,
|
||||||
|
is_blocked integer check(is_blocked in (0, 1)) default 0,
|
||||||
|
primary key (user_id, project_id)
|
||||||
|
) strict;
|
||||||
|
|
||||||
|
create table if not exists tasks (
|
||||||
|
id integer primary key autoincrement,
|
||||||
|
project_id integer references projects(id) on delete cascade,
|
||||||
|
name text not null check(trim(name) <> '' and length(name) < 4096),
|
||||||
|
created_by integer references users(id) on delete set null,
|
||||||
|
assigned_to integer references users(id) on delete set null,
|
||||||
|
closed_by integer references users(id) on delete set null,
|
||||||
|
priority integer check(priority in (0, 1, 2, 3, 4, 5)) default 0,
|
||||||
|
status integer check(status >= 1 and status <= 10) default 1,
|
||||||
|
time_spent integer check(time_spent is null or time_spent > 0 and time_spent < 44640), -- one month
|
||||||
|
create_date integer,
|
||||||
|
plan_date integer,
|
||||||
|
close_date integer
|
||||||
|
) strict;
|
||||||
|
|
||||||
|
create table if not exists meetings (
|
||||||
|
id integer primary key autoincrement,
|
||||||
|
project_id integer references projects(id) on delete cascade,
|
||||||
|
name text not null check(trim(name) <> '' and length(name) < 4096),
|
||||||
|
description text check(description is null or length(description) < 4096),
|
||||||
|
created_by integer references users(id) on delete set null,
|
||||||
|
meet_date integer
|
||||||
|
) strict;
|
||||||
|
|
||||||
|
create table if not exists documents (
|
||||||
|
id integer primary key autoincrement,
|
||||||
|
project_id integer references projects(id) on delete cascade,
|
||||||
|
origin_group_id integer references groups(id) on delete set null,
|
||||||
|
origin_message_id integer,
|
||||||
|
group_id integer references groups(id) on delete set null,
|
||||||
|
message_id integer,
|
||||||
|
file_id integer,
|
||||||
|
access_hash integer,
|
||||||
|
filename text not null check(length(filename) < 256),
|
||||||
|
mime text check(mime is null or length(mime) < 128),
|
||||||
|
caption text check(caption is null or length(caption) < 4096),
|
||||||
|
size integer,
|
||||||
|
published_by integer references users(id) on delete set null,
|
||||||
|
parent_type integer check(parent_type in (0, 1, 2)) default 0,
|
||||||
|
parent_id integer,
|
||||||
|
backup_state integer default 0
|
||||||
|
) strict;
|
||||||
|
|
||||||
|
create table if not exists companies (
|
||||||
|
id integer primary key autoincrement,
|
||||||
|
project_id integer references projects(id) on delete cascade,
|
||||||
|
name text not null check(length(name) < 4096),
|
||||||
|
email text check(email is null or length(email) < 128),
|
||||||
|
phone text check(phone is null or length(phone) < 128),
|
||||||
|
site text check(site is null or length(site) < 128),
|
||||||
|
description text check(description is null or length(description) < 4096),
|
||||||
|
logo text
|
||||||
|
) strict;
|
||||||
|
|
||||||
|
create table if not exists company_mappings (
|
||||||
|
project_id integer references projects(id) on delete cascade,
|
||||||
|
company_id integer references companies(id) on delete cascade,
|
||||||
|
show_as_id integer references companies(id) on delete cascade,
|
||||||
|
show_to_id integer references companies(id) on delete cascade
|
||||||
|
) strict;
|
||||||
|
|
||||||
|
create table if not exists task_users (
|
||||||
|
task_id integer references tasks(id) on delete cascade,
|
||||||
|
user_id integer references users(id) on delete cascade,
|
||||||
|
primary key (task_id, user_id)
|
||||||
|
) without rowid;
|
||||||
|
|
||||||
|
create table if not exists meeting_users (
|
||||||
|
meeting_id integer references meetings(id) on delete cascade,
|
||||||
|
user_id integer references users(id) on delete cascade,
|
||||||
|
primary key (meeting_id, user_id)
|
||||||
|
) without rowid;
|
||||||
|
|
||||||
|
create table if not exists company_users (
|
||||||
|
company_id integer references companies(id) on delete cascade,
|
||||||
|
user_id integer references users(id) on delete cascade,
|
||||||
|
primary key (company_id, user_id)
|
||||||
|
) without rowid;
|
||||||
|
|
||||||
|
create table if not exists group_users (
|
||||||
|
group_id integer references groups(id) on delete cascade,
|
||||||
|
user_id integer references users(id) on delete cascade,
|
||||||
|
primary key (group_id, user_id)
|
||||||
|
) without rowid;
|
||||||
|
|
||||||
|
pragma foreign_keys = on;
|
||||||
|
|
||||||
|
|
||||||
|
create trigger if not exists trg_groups_update after update
|
||||||
|
on groups
|
||||||
|
when NEW.project_id is null
|
||||||
|
begin
|
||||||
|
delete from group_users where group_id = NEW.id;
|
||||||
|
end;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
57
backend/include/db.js
Normal file
57
backend/include/db.js
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
const fs = require('fs')
|
||||||
|
const crypto = require('crypto')
|
||||||
|
const sqlite3 = require('better-sqlite3')
|
||||||
|
|
||||||
|
const db = sqlite3(`./data/db.sqlite`)
|
||||||
|
db.pragma('journal_mode = WAL')
|
||||||
|
|
||||||
|
db.exec(fs.readFileSync('./data/init.sql', 'utf8'))
|
||||||
|
|
||||||
|
/*
|
||||||
|
db.exec(`attach database './data/backup.sqlite' as backup`)
|
||||||
|
|
||||||
|
db.function('backup', (tblname, ...values) => {
|
||||||
|
db
|
||||||
|
.prepare(`insert into backup.${tblname} select ` + values.map(e => '?').join(', '))
|
||||||
|
.run(values)
|
||||||
|
})
|
||||||
|
|
||||||
|
const backupQuery = db
|
||||||
|
.prepare(`
|
||||||
|
select group_concat(tbl || char(13) || trg, char(13) || char(13)) from (
|
||||||
|
select 'create table if not exists backup.' || t.name || ' (' || group_concat(c.name || ' ' || c.type, ', ') || ', time integer);' tbl,
|
||||||
|
'create trigger if not exists trg_' || t.name || '_delete after delete on ' || t.name || ' for each row begin ' ||
|
||||||
|
' select backup (' || t.name || ',' || group_concat('OLD.' || c.name, ', ') || ', strftime(''%s'', ''now'')); end;' trg
|
||||||
|
from sqlite_master t left join pragma_table_xinfo c on t.tbl_name = c.arg and c.schema = 'main'
|
||||||
|
where t.sql is not null and t.type = 'table' and t.name <> 'sqlite_sequence'
|
||||||
|
group by t.type, t.name order by t.type, t.name)
|
||||||
|
`)
|
||||||
|
.pluck(true)
|
||||||
|
.get()
|
||||||
|
db.exec(backupQuery)
|
||||||
|
*/
|
||||||
|
|
||||||
|
db.function('generate_key', (id, time) => {
|
||||||
|
return [
|
||||||
|
'KEY',
|
||||||
|
Buffer.from(time + '').toString('base64'),
|
||||||
|
crypto.createHash('md5').update(`sa${time}-${id}lt`).digest('hex')
|
||||||
|
].join('-')
|
||||||
|
})
|
||||||
|
|
||||||
|
process.on('exit', () => db.close())
|
||||||
|
process.on('SIGHUP', () => process.exit(128 + 1))
|
||||||
|
process.on('SIGINT', () => process.exit(128 + 2))
|
||||||
|
process.on('SIGTERM', () => process.exit(128 + 15))
|
||||||
|
|
||||||
|
db.prepareUpdate = function (table, columns, data, where) {
|
||||||
|
const dataColumns = columns.filter(col => col in data)
|
||||||
|
if (dataColumns.length == 0)
|
||||||
|
throw Error('SQLite Error: No data to update')
|
||||||
|
|
||||||
|
return db.prepare(`update "${table}" ` +
|
||||||
|
`set ` + dataColumns.map(col => `"${col}" = :${col}`).join(', ') +
|
||||||
|
` where ` + where.map(col => `"${col}" = :${col}`).join(' and '))
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = db
|
||||||
1990
backend/package-lock.json
generated
Normal file
1990
backend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
25
backend/package.json
Normal file
25
backend/package.json
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"name": "telegram-bot",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"main": "app.js",
|
||||||
|
"directories": {
|
||||||
|
"doc": "docs"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"test": "echo \"Error: no test specified\" && exit 1"
|
||||||
|
},
|
||||||
|
"author": "",
|
||||||
|
"license": "ISC",
|
||||||
|
"description": "",
|
||||||
|
"dependencies": {
|
||||||
|
"better-sqlite3": "^11.8.0",
|
||||||
|
"body-parser": "^1.20.3",
|
||||||
|
"content-disposition": "^0.5.4",
|
||||||
|
"cookie-parser": "^1.4.7",
|
||||||
|
"express": "^4.21.2",
|
||||||
|
"express-session": "^1.18.1",
|
||||||
|
"multer": "^1.4.5-lts.1",
|
||||||
|
"nodemailer": "^6.9.16",
|
||||||
|
"telegram": "^2.26.16"
|
||||||
|
}
|
||||||
|
}
|
||||||
337
backend/public/index.html
Normal file
337
backend/public/index.html
Normal file
@@ -0,0 +1,337 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, minimum-scale=1.0, maximum-scale=1.0, user-scalable=no" />
|
||||||
|
<meta name="format-detection" content="telephone=no" />
|
||||||
|
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
|
||||||
|
<meta name="MobileOptimized" content="176" />
|
||||||
|
<meta name="HandheldFriendly" content="True" />
|
||||||
|
<meta name="robots" content="noindex,nofollow" />
|
||||||
|
<script src="https://telegram.org/js/telegram-web-app.js?56"></script>
|
||||||
|
<title></title>
|
||||||
|
<style>
|
||||||
|
*[hidden] {display: none;}
|
||||||
|
.block {border: 1px black solid; padding:10px; margin-bottom: 10px;}
|
||||||
|
#settings input {width: 45%;}
|
||||||
|
#delete {color:red; cursor: pointer; margin-left: 10px;}
|
||||||
|
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<div id = "adminapp" hidden>
|
||||||
|
<b>Админка</b>
|
||||||
|
<div id = "settings" class = "block">
|
||||||
|
<b>Настройки</b><br>
|
||||||
|
<input id = "name" type = "text" placeholder = "Name">
|
||||||
|
<div>
|
||||||
|
<button id = "upload-group" href = "">Не задано</button>
|
||||||
|
<button id = "upload-group-selector" href = "" admin>Задать</button>
|
||||||
|
</div>
|
||||||
|
<b>Company</b>
|
||||||
|
<div id = "company">
|
||||||
|
<input id = "company-name" type = "text" placeholder = "Name">
|
||||||
|
<input id = "company-phone" type = "text" placeholder = "Phone">
|
||||||
|
<input id = "company-site" type = "text" placeholder = "Site">
|
||||||
|
<input id = "company-description" type = "text" placeholder = "Description">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button id = "update-profile">Update</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id = "projects" class = "block">
|
||||||
|
<b>Список проектов</b><button id = "add-project">Добавить/Обновить</button>
|
||||||
|
<br>
|
||||||
|
<input id = "project-id" placeholder = "Id">
|
||||||
|
<input id = "project-name" placeholder = "Name">
|
||||||
|
<input id = "project-description" placeholder = "Description">
|
||||||
|
<br>
|
||||||
|
<div id = "project-list">Loading...</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id = "groups" class = "block">
|
||||||
|
<b>Список групп</b><button id = "add-group" href="" admin>Добавить</button>
|
||||||
|
<br>
|
||||||
|
<div id = "group-list">Проект не выбран</div>
|
||||||
|
<button id = "share" href = "">Отправить ключ подключения</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id = "users" class = "block">
|
||||||
|
<b>Список пользователей</b>
|
||||||
|
<br>
|
||||||
|
<div id = "user-list">Проект не выбран</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id = "companies" class = "block">
|
||||||
|
<b>Список компаний</b><button id = "add-company">Добавить/Обновить</button>
|
||||||
|
<br>
|
||||||
|
<input id = "company-id" placeholder = "Id">
|
||||||
|
<input id = "company-name" placeholder = "Name">
|
||||||
|
<input id = "company-description" placeholder = "Description">
|
||||||
|
<br>
|
||||||
|
<div id = "company-list">Проект не выбран</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<div id = "miniapp" style = "min-height: 20px; border: 1px black solid; padding:10px; word-break: break-all;" hidden>
|
||||||
|
Пользов
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script type="text/javascript">
|
||||||
|
const $miniapp = document.getElementById('miniapp')
|
||||||
|
const $adminapp = document.getElementById('adminapp')
|
||||||
|
|
||||||
|
$adminapp.querySelector('#settings #update-profile').addEventListener('click', async function (event) {
|
||||||
|
const $e = this.parentNode
|
||||||
|
const data = {
|
||||||
|
name: $e.querySelector('#name').value,
|
||||||
|
company: {
|
||||||
|
name: $e.querySelector('#company-name').value,
|
||||||
|
phone: $e.querySelector('#company-phone').value,
|
||||||
|
site: $e.querySelector('#company-site').value,
|
||||||
|
description: $e.querySelector('#company-description').value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await fetch('/api/admin/customer/profile', {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
}
|
||||||
|
}).then(res => res.json())
|
||||||
|
console.log(res)
|
||||||
|
})
|
||||||
|
|
||||||
|
$adminapp.querySelector('#projects #project-list').addEventListener('click', async function (event) {
|
||||||
|
const $e = event.target
|
||||||
|
if ($e.id != 'delete')
|
||||||
|
return
|
||||||
|
|
||||||
|
const id = $e.parentNode.id
|
||||||
|
|
||||||
|
const res = await fetch('/api/admin/project/' + id, {
|
||||||
|
method: 'DELETE'
|
||||||
|
}).then(res => res.json())
|
||||||
|
.then(res => $e.parentNode.remove())
|
||||||
|
.catch(alert)
|
||||||
|
})
|
||||||
|
|
||||||
|
$adminapp.querySelector('#projects #project-list').addEventListener('click', async function (event) {
|
||||||
|
const $e = event.target
|
||||||
|
if ($e.parentNode.id != 'project-list')
|
||||||
|
return
|
||||||
|
|
||||||
|
$adminapp.querySelector('#projects #project-id').value = $e.id
|
||||||
|
$adminapp.querySelector('#projects #project-name').value = $e.getAttribute('name')
|
||||||
|
$adminapp.querySelector('#projects #project-description').value = $e.getAttribute('title')
|
||||||
|
|
||||||
|
$adminapp.querySelector('#groups #add-group').setAttribute('href', 'https://t.me/ready_or_not_2025_bot?startgroup=' + $e.id)
|
||||||
|
|
||||||
|
reloadGroups($e.id)
|
||||||
|
reloadUsers($e.id)
|
||||||
|
reloadCompanies($e.id)
|
||||||
|
|
||||||
|
const reqKey = await fetch('/api/admin/project/' + $e.id + '/token').then(res => res.json())
|
||||||
|
const url = 'https://t.me/share/url?url=' + reqKey.data
|
||||||
|
$adminapp.querySelector('#groups #share').setAttribute('href', url)
|
||||||
|
})
|
||||||
|
|
||||||
|
$adminapp.querySelector('#companies #company-list').addEventListener('click', async function (event) {
|
||||||
|
const $e = event.target
|
||||||
|
if ($e.id != 'delete')
|
||||||
|
return
|
||||||
|
|
||||||
|
const id = $e.parentNode.id
|
||||||
|
const project_id = $adminapp.querySelector('#groups #group-list').getAttribute('project-id')
|
||||||
|
|
||||||
|
const res = await fetch(`/api/admin/project/${project_id}/company/${id}`, {
|
||||||
|
method: 'DELETE'
|
||||||
|
}).then(res => res.json())
|
||||||
|
.then(res => $e.parentNode.remove())
|
||||||
|
.catch(alert)
|
||||||
|
})
|
||||||
|
|
||||||
|
$adminapp.querySelector('#companies #company-list').addEventListener('click', async function (event) {
|
||||||
|
const $e = event.target
|
||||||
|
if ($e.parentNode.id != 'company-list')
|
||||||
|
return
|
||||||
|
|
||||||
|
$adminapp.querySelector('#companies #company-id').value = $e.id
|
||||||
|
$adminapp.querySelector('#companies #company-name').value = $e.getAttribute('name')
|
||||||
|
$adminapp.querySelector('#companies #company-description').value = $e.getAttribute('title')
|
||||||
|
})
|
||||||
|
|
||||||
|
$adminapp.querySelector('#groups #group-list').addEventListener('click', async function (event) {
|
||||||
|
const $e = event.target
|
||||||
|
if ($e.id != 'delete')
|
||||||
|
return
|
||||||
|
|
||||||
|
const id = $e.parentNode.id
|
||||||
|
const project_id = $adminapp.querySelector('#groups #group-list').getAttribute('project-id')
|
||||||
|
|
||||||
|
const res = await fetch(`/api/admin/project/${project_id}/group/${id}`, {
|
||||||
|
method: 'DELETE'
|
||||||
|
}).then(res => res.json())
|
||||||
|
.then(res => $e.parentNode.remove())
|
||||||
|
.catch(alert)
|
||||||
|
})
|
||||||
|
|
||||||
|
$adminapp.querySelectorAll('button[href]')
|
||||||
|
.forEach($e => {
|
||||||
|
$e.addEventListener('click', async function (event) {
|
||||||
|
// &admin=change_info+delete_messages+restrict_members+invite_users+pin_messages+promote_members
|
||||||
|
let url = this.getAttribute('href')
|
||||||
|
if ($e.hasAttribute('admin'))
|
||||||
|
url += '&admin=change_info+pin_messages'
|
||||||
|
window.Telegram.WebApp.openTelegramLink(url)
|
||||||
|
})
|
||||||
|
|
||||||
|
})
|
||||||
|
|
||||||
|
async function reloadProjects() {
|
||||||
|
const reqProjects = await fetch('/api/admin/project').then(res => res.json())
|
||||||
|
|
||||||
|
const projects = reqProjects.data
|
||||||
|
const $projectList = $adminapp.querySelector('#projects #project-list')
|
||||||
|
$projectList.innerHTML = projects.map(e => `<div id = "${e.id}" title = "${e.description}" name = "${e.name}">${e.name}<span id = "delete">x</div></div>`).join('') || 'Пусто'
|
||||||
|
}
|
||||||
|
|
||||||
|
async function reloadGroups(projectId) {
|
||||||
|
const req = await fetch('/api/admin/project/' + projectId + '/group').then(res => res.json())
|
||||||
|
|
||||||
|
const groups = req.data
|
||||||
|
const $groupList = $adminapp.querySelector('#groups #group-list')
|
||||||
|
$groupList.setAttribute('project-id', projectId)
|
||||||
|
$groupList.innerHTML = groups.map(e => `<div id = "${e.id}" name = "${e.name}">${e.name}<span id = "delete">x</div></div>`).join('') || 'Пусто'
|
||||||
|
}
|
||||||
|
|
||||||
|
async function reloadCompanies(projectId) {
|
||||||
|
const req = await fetch('/api/admin/project/' + projectId + '/company').then(res => res.json())
|
||||||
|
|
||||||
|
const companies = req.data
|
||||||
|
const $companyList = $adminapp.querySelector('#companies #company-list')
|
||||||
|
$companyList.setAttribute('project-id', projectId)
|
||||||
|
$companyList.innerHTML = companies.map(e => `<div id = "${e.id}" name = "${e.name}" title = "${e.description}">${e.name}<span id = "delete">x</div></div>`).join('') || 'Пусто'
|
||||||
|
}
|
||||||
|
|
||||||
|
async function reloadUsers(projectId) {
|
||||||
|
const req = await fetch('/api/admin/project/' + projectId + '/user').then(res => res.json())
|
||||||
|
|
||||||
|
const users = req.data
|
||||||
|
const $userList = $adminapp.querySelector('#users #user-list')
|
||||||
|
$userList.setAttribute('user-id', projectId)
|
||||||
|
$userList.innerHTML = users.map(e => `<div id = "${e.id}">${e.firstname} ${e.lastname}</div>`).join('') || 'Пусто'
|
||||||
|
}
|
||||||
|
|
||||||
|
$adminapp.querySelector('#projects #add-project').addEventListener('click', async function (event) {
|
||||||
|
const $e = this.parentNode
|
||||||
|
const id = +$e.querySelector('#project-id').value
|
||||||
|
|
||||||
|
const data = {
|
||||||
|
name: $e.querySelector('#project-name').value,
|
||||||
|
description: $e.querySelector('#project-description').value
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = id ? '/api/admin/project/' + id : '/api/admin/project'
|
||||||
|
const method = id ? 'PUT' : 'POST'
|
||||||
|
|
||||||
|
|
||||||
|
await fetch(url, {
|
||||||
|
method,
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
}
|
||||||
|
}).then(res => res.json()).then(() => {
|
||||||
|
reloadProjects()
|
||||||
|
$e.querySelector('#project-id').value = ''
|
||||||
|
$e.querySelector('#project-name').value = ''
|
||||||
|
$e.querySelector('#project-description').value = ''
|
||||||
|
}).catch(alert)
|
||||||
|
})
|
||||||
|
|
||||||
|
$adminapp.querySelector('#companies #add-company').addEventListener('click', async function (event) {
|
||||||
|
const $e = this.parentNode
|
||||||
|
const project_id = +$adminapp.querySelector('#groups #group-list').getAttribute('project-id')
|
||||||
|
const id = +$e.querySelector('#company-id').value
|
||||||
|
|
||||||
|
const data = {
|
||||||
|
name: $e.querySelector('#company-name').value,
|
||||||
|
description: $e.querySelector('#company-description').value
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = '/api/admin/project/' + project_id + '/company' + (id ? '/' + id : '')
|
||||||
|
const method = id ? 'PUT' : 'POST'
|
||||||
|
|
||||||
|
|
||||||
|
await fetch(url, {
|
||||||
|
method,
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
}
|
||||||
|
}).then(res => res.json()).then(() => {
|
||||||
|
reloadCompanies(project_id)
|
||||||
|
$e.querySelector('#company-id').value = ''
|
||||||
|
$e.querySelector('#company-name').value = ''
|
||||||
|
$e.querySelector('#company-description').value = ''
|
||||||
|
}).catch(alert)
|
||||||
|
})
|
||||||
|
|
||||||
|
try {
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
const startParams = (Telegram.WebApp.initDataUnsafe.start_param || '').split('_')
|
||||||
|
const isAdmin = startParams[0] == 'admin'
|
||||||
|
const $app = isAdmin ? $adminapp : $miniapp
|
||||||
|
$app.hidden = false
|
||||||
|
|
||||||
|
const login_url = isAdmin ? '/api/admin/customer/login?' : '/api/miniapp/user/login?'
|
||||||
|
const login = await fetch(login_url + Telegram.WebApp.initData, {method: 'POST'}).then(res => res.json())
|
||||||
|
console.log(login)
|
||||||
|
|
||||||
|
|
||||||
|
if (isAdmin) {
|
||||||
|
const reqProfile = await fetch('/api/admin/customer/profile').then(res => res.json())
|
||||||
|
const profile = reqProfile.data
|
||||||
|
|
||||||
|
$adminapp.querySelector('#settings #name').value = profile.name || ''
|
||||||
|
$adminapp.querySelector('#settings #company-name').value = profile.company?.name || ''
|
||||||
|
$adminapp.querySelector('#settings #company-phone').value = profile.company?.phone || ''
|
||||||
|
$adminapp.querySelector('#settings #company-site').value = profile.company?.site || ''
|
||||||
|
$adminapp.querySelector('#settings #company-description').value = profile.company?.description || ''
|
||||||
|
$adminapp.querySelector('#settings #upload-group-selector').setAttribute('href', 'https://t.me/ready_or_not_2025_bot?startgroup=-' + profile.id)
|
||||||
|
|
||||||
|
const $uploadGroup = $adminapp.querySelector('#settings #upload-group')
|
||||||
|
if (profile.upload_group) {
|
||||||
|
$uploadGroup.innerHTML = profile.upload_group.name || 'Ошибка'
|
||||||
|
$uploadGroup.setAttribute('href', 'https://t.me/c/' + profile.upload_group.telegram_id)
|
||||||
|
} else {
|
||||||
|
$uploadGroup.innerHTML = 'Не задано'
|
||||||
|
$uploadGroup.setAttribute('href', '')
|
||||||
|
}
|
||||||
|
|
||||||
|
await reloadProjects()
|
||||||
|
} else {
|
||||||
|
// Пользовательский
|
||||||
|
if (startParams[1])
|
||||||
|
alert('Группа на проекте ' + startParams[1])
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
alert(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
65
backend/public/miniapp.html
Normal file
65
backend/public/miniapp.html
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<body>
|
||||||
|
<style>
|
||||||
|
.block {margin-bottom: 10px}
|
||||||
|
#projects > div {cursor: pointer}
|
||||||
|
</style>
|
||||||
|
<div class = "block">
|
||||||
|
<label for = "user">USER</label>
|
||||||
|
<select id = "user"></select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class = "block">
|
||||||
|
<div id = "project-title">ПРОЕКТЫ</div>
|
||||||
|
<div id = "projects">EMPTY</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class = "block">
|
||||||
|
<div id = "member-title">УЧАСТНИКИ</div>
|
||||||
|
<div id = "members"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<script type="text/javascript">
|
||||||
|
(async function () {
|
||||||
|
try {
|
||||||
|
const $user = document.getElementById('user')
|
||||||
|
const $projects = document.getElementById('projects')
|
||||||
|
const $members = document.getElementById('members')
|
||||||
|
|
||||||
|
let members = {}
|
||||||
|
|
||||||
|
$user.addEventListener('change', async () => {
|
||||||
|
const user_id = $user.value
|
||||||
|
const projects = await fetch('/api/miniapp/project?user_id=' + user_id).then(res => res.json())
|
||||||
|
$projects.innerHTML = projects.data.map(e =>
|
||||||
|
`<div id = "${e.id}" title = '${JSON.stringify(e)}'>${e.name} - ${e.description}</div>`
|
||||||
|
).join('')
|
||||||
|
$members.innerHTML = ''
|
||||||
|
})
|
||||||
|
|
||||||
|
$projects.addEventListener('click', async (evt) => {
|
||||||
|
if (evt.target.parentNode != $projects)
|
||||||
|
return
|
||||||
|
|
||||||
|
const _members = await fetch('/api/miniapp/project/' + evt.target.id + '/member?user_id=' + $user.value).then(res => res.json())
|
||||||
|
members = _members.data
|
||||||
|
$members.innerHTML = members.map(e =>
|
||||||
|
`<div id = "${e.id}" title = '${JSON.stringify(e)}' is-blocked = '${e.is_blocked}'>${e.id} - ${e.telegram_name}</div>`
|
||||||
|
).join('')
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
const user_ids = await fetch('/api/miniapp/user').then(res => res.json())
|
||||||
|
$user.innerHTML = user_ids.data.map(id => `<option value = '${id}'>${id}</option>`).join('')
|
||||||
|
$user.dispatchEvent(new Event('change'))
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err)
|
||||||
|
alert(err.message)
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
BIN
i18n-2.xlsm
BIN
i18n-2.xlsm
Binary file not shown.
@@ -12,9 +12,8 @@
|
|||||||
<meta name="MobileOptimized" content="176"/>
|
<meta name="MobileOptimized" content="176"/>
|
||||||
<meta name="HandheldFriendly" content="True"/>
|
<meta name="HandheldFriendly" content="True"/>
|
||||||
<meta name="robots" content="noindex, nofollow"/>
|
<meta name="robots" content="noindex, nofollow"/>
|
||||||
|
|
||||||
|
|
||||||
<meta name="msapplication-tap-highlight" content="no">
|
<meta name="msapplication-tap-highlight" content="no">
|
||||||
|
<script src="https://telegram.org/js/telegram-web-app.js"></script>
|
||||||
<!--
|
<!--
|
||||||
<meta name="viewport" content="user-scalable=no, initial-scale=1, maximum-scale=1, minimum-scale=1, width=device-width<% if (ctx.mode.cordova || ctx.mode.capacitor) { %>, viewport-fit=cover<% } %>">
|
<meta name="viewport" content="user-scalable=no, initial-scale=1, maximum-scale=1, minimum-scale=1, width=device-width<% if (ctx.mode.cordova || ctx.mode.capacitor) { %>, viewport-fit=cover<% } %>">
|
||||||
-->
|
-->
|
||||||
@@ -23,7 +22,6 @@
|
|||||||
<link rel="icon" type="image/png" sizes="32x32" href="icons/favicon-32x32.png">
|
<link rel="icon" type="image/png" sizes="32x32" href="icons/favicon-32x32.png">
|
||||||
<link rel="icon" type="image/png" sizes="16x16" href="icons/favicon-16x16.png">
|
<link rel="icon" type="image/png" sizes="16x16" href="icons/favicon-16x16.png">
|
||||||
<link rel="icon" type="image/ico" href="favicon.ico">
|
<link rel="icon" type="image/ico" href="favicon.ico">
|
||||||
<script src="http://localhost:11111"></script>
|
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<!-- quasar:entry-point -->
|
<!-- quasar:entry-point -->
|
||||||
|
|||||||
16
package-lock.json
generated
16
package-lock.json
generated
@@ -22,7 +22,9 @@
|
|||||||
"@eslint/js": "^9.14.0",
|
"@eslint/js": "^9.14.0",
|
||||||
"@intlify/unplugin-vue-i18n": "^2.0.0",
|
"@intlify/unplugin-vue-i18n": "^2.0.0",
|
||||||
"@quasar/app-vite": "^2.0.0",
|
"@quasar/app-vite": "^2.0.0",
|
||||||
|
"@twa-dev/types": "^8.0.2",
|
||||||
"@types/node": "^20.17.30",
|
"@types/node": "^20.17.30",
|
||||||
|
"@types/telegram-web-app": "^7.10.1",
|
||||||
"@vue/devtools": "^7.7.2",
|
"@vue/devtools": "^7.7.2",
|
||||||
"@vue/eslint-config-typescript": "^14.1.3",
|
"@vue/eslint-config-typescript": "^14.1.3",
|
||||||
"autoprefixer": "^10.4.2",
|
"autoprefixer": "^10.4.2",
|
||||||
@@ -2069,6 +2071,13 @@
|
|||||||
"node": ">=14.16"
|
"node": ">=14.16"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@twa-dev/types": {
|
||||||
|
"version": "8.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@twa-dev/types/-/types-8.0.2.tgz",
|
||||||
|
"integrity": "sha512-ICQ6n4NaUPPzV3/GzflVQS6Nnu5QX2vr9OlOG8ZkFf3rSJXzRKazrLAbZlVhCPPWkIW3MMuELPsE6tByrA49qA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@types/body-parser": {
|
"node_modules/@types/body-parser": {
|
||||||
"version": "1.19.5",
|
"version": "1.19.5",
|
||||||
"resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.5.tgz",
|
"resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.5.tgz",
|
||||||
@@ -2316,6 +2325,13 @@
|
|||||||
"@types/send": "*"
|
"@types/send": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/telegram-web-app": {
|
||||||
|
"version": "7.10.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/telegram-web-app/-/telegram-web-app-7.10.1.tgz",
|
||||||
|
"integrity": "sha512-GKL659G6lnHRAt3Dt7L1Fa0dwvc+ZZQtJcYrJVJGwjvsbi/Rcp1qKs9pFwb34/KC04+J+TlQj4D7yKo10sBSEw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@types/yauzl": {
|
"node_modules/@types/yauzl": {
|
||||||
"version": "2.10.3",
|
"version": "2.10.3",
|
||||||
"resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz",
|
"resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz",
|
||||||
|
|||||||
@@ -27,7 +27,9 @@
|
|||||||
"@eslint/js": "^9.14.0",
|
"@eslint/js": "^9.14.0",
|
||||||
"@intlify/unplugin-vue-i18n": "^2.0.0",
|
"@intlify/unplugin-vue-i18n": "^2.0.0",
|
||||||
"@quasar/app-vite": "^2.0.0",
|
"@quasar/app-vite": "^2.0.0",
|
||||||
|
"@twa-dev/types": "^8.0.2",
|
||||||
"@types/node": "^20.17.30",
|
"@types/node": "^20.17.30",
|
||||||
|
"@types/telegram-web-app": "^7.10.1",
|
||||||
"@vue/devtools": "^7.7.2",
|
"@vue/devtools": "^7.7.2",
|
||||||
"@vue/eslint-config-typescript": "^14.1.3",
|
"@vue/eslint-config-typescript": "^14.1.3",
|
||||||
"autoprefixer": "^10.4.2",
|
"autoprefixer": "^10.4.2",
|
||||||
|
|||||||
@@ -3,7 +3,6 @@
|
|||||||
|
|
||||||
import { defineConfig } from '#q-app/wrappers'
|
import { defineConfig } from '#q-app/wrappers'
|
||||||
import { fileURLToPath } from 'node:url'
|
import { fileURLToPath } from 'node:url'
|
||||||
import path from 'node:path'
|
|
||||||
|
|
||||||
export default defineConfig((ctx) => {
|
export default defineConfig((ctx) => {
|
||||||
return {
|
return {
|
||||||
@@ -16,7 +15,9 @@ export default defineConfig((ctx) => {
|
|||||||
boot: [
|
boot: [
|
||||||
'i18n',
|
'i18n',
|
||||||
'axios',
|
'axios',
|
||||||
'global-components'
|
'auth-init',
|
||||||
|
'global-components',
|
||||||
|
'telegram-boot'
|
||||||
],
|
],
|
||||||
|
|
||||||
// https://v2.quasar.dev/quasar-cli-vite/quasar-config-file#css
|
// https://v2.quasar.dev/quasar-cli-vite/quasar-config-file#css
|
||||||
@@ -52,6 +53,8 @@ export default defineConfig((ctx) => {
|
|||||||
},
|
},
|
||||||
|
|
||||||
vueRouterMode: 'history', // available values: 'hash', 'history'
|
vueRouterMode: 'history', // available values: 'hash', 'history'
|
||||||
|
vueDevtools: true, // Должно быть true
|
||||||
|
devtool: 'source-map', // Для лучшей отладки
|
||||||
// vueRouterBase,
|
// vueRouterBase,
|
||||||
// vueDevtools,
|
// vueDevtools,
|
||||||
// vueOptionsAPI: false,
|
// vueOptionsAPI: false,
|
||||||
@@ -101,13 +104,22 @@ export default defineConfig((ctx) => {
|
|||||||
// Full list of options: https://v2.quasar.dev/quasar-cli-vite/quasar-config-file#devserver
|
// Full list of options: https://v2.quasar.dev/quasar-cli-vite/quasar-config-file#devserver
|
||||||
devServer: {
|
devServer: {
|
||||||
vueDevtools: true,
|
vueDevtools: true,
|
||||||
|
port: 9000,
|
||||||
|
proxy: {
|
||||||
|
'/api': {
|
||||||
|
target: 'http://localhost:3000',
|
||||||
|
changeOrigin: true
|
||||||
|
}
|
||||||
|
}
|
||||||
// https: true,
|
// https: true,
|
||||||
open: true // opens browser window automatically
|
// open: true // opens browser window automatically
|
||||||
},
|
},
|
||||||
|
|
||||||
// https://v2.quasar.dev/quasar-cli-vite/quasar-config-file#framework
|
// https://v2.quasar.dev/quasar-cli-vite/quasar-config-file#framework
|
||||||
framework: {
|
framework: {
|
||||||
config: {},
|
config: {
|
||||||
|
},
|
||||||
|
|
||||||
|
|
||||||
// iconSet: 'material-icons', // Quasar icon set
|
// iconSet: 'material-icons', // Quasar icon set
|
||||||
// lang: 'en-US', // Quasar language pack
|
// lang: 'en-US', // Quasar language pack
|
||||||
@@ -120,7 +132,7 @@ export default defineConfig((ctx) => {
|
|||||||
// directives: [],
|
// directives: [],
|
||||||
|
|
||||||
// Quasar plugins
|
// Quasar plugins
|
||||||
plugins: []
|
plugins: [ 'Notify' ]
|
||||||
},
|
},
|
||||||
|
|
||||||
// animations: 'all', // --- includes all animations
|
// animations: 'all', // --- includes all animations
|
||||||
|
|||||||
21
src/App.vue
21
src/App.vue
@@ -3,5 +3,24 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
//
|
import { inject, onMounted } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import type { WebApp } from '@twa-dev/types'
|
||||||
|
import { useTextSizeStore } from './stores/textSize'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const tg = inject('tg') as WebApp
|
||||||
|
tg.onEvent('settingsButtonClicked', async () => {
|
||||||
|
await router.push({ name: 'settings' })
|
||||||
|
})
|
||||||
|
|
||||||
|
const textSizeStore = useTextSizeStore()
|
||||||
|
onMounted(async () => {
|
||||||
|
try {
|
||||||
|
await textSizeStore.initialize()
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error load font size:', err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
6
src/boot/auth-init.ts
Normal file
6
src/boot/auth-init.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { useAuthStore } from 'stores/auth'
|
||||||
|
|
||||||
|
export default async () => {
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
await authStore.initialize()
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { defineBoot } from '#q-app/wrappers';
|
import { defineBoot } from '#q-app/wrappers'
|
||||||
import axios, { type AxiosInstance } from 'axios';
|
import axios, { type AxiosInstance } from 'axios'
|
||||||
|
import { useAuthStore } from 'src/stores/auth'
|
||||||
|
|
||||||
declare module 'vue' {
|
declare module 'vue' {
|
||||||
interface ComponentCustomProperties {
|
interface ComponentCustomProperties {
|
||||||
@@ -14,16 +15,32 @@ declare module 'vue' {
|
|||||||
// good idea to move this instance creation inside of the
|
// good idea to move this instance creation inside of the
|
||||||
// "export default () => {}" function below (which runs individually
|
// "export default () => {}" function below (which runs individually
|
||||||
// for each client)
|
// for each client)
|
||||||
const api = axios.create({ baseURL: 'https://api.example.com' });
|
const api = axios.create({
|
||||||
|
baseURL: '/',
|
||||||
|
withCredentials: true // Важно для работы с cookies
|
||||||
|
})
|
||||||
|
|
||||||
|
api.interceptors.response.use(
|
||||||
|
response => response,
|
||||||
|
async error => {
|
||||||
|
if (error.response?.status === 401) {
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
await authStore.logout()
|
||||||
|
}
|
||||||
|
console.error(error)
|
||||||
|
return Promise.reject(new Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
)
|
||||||
|
|
||||||
export default defineBoot(({ app }) => {
|
export default defineBoot(({ app }) => {
|
||||||
// for use inside Vue files (Options API) through this.$axios and this.$api
|
// for use inside Vue files (Options API) through this.$axios and this.$api
|
||||||
|
|
||||||
app.config.globalProperties.$axios = axios;
|
app.config.globalProperties.$axios = axios
|
||||||
// ^ ^ ^ this will allow you to use this.$axios (for Vue Options API form)
|
// ^ ^ ^ this will allow you to use this.$axios (for Vue Options API form)
|
||||||
// so you won't necessarily have to import axios in each vue file
|
// so you won't necessarily have to import axios in each vue file
|
||||||
|
|
||||||
app.config.globalProperties.$api = api;
|
app.config.globalProperties.$api = api
|
||||||
// ^ ^ ^ this will allow you to use this.$api (for Vue Options API form)
|
// ^ ^ ^ this will allow you to use this.$api (for Vue Options API form)
|
||||||
// so you can easily perform requests against your app's API
|
// so you can easily perform requests against your app's API
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,8 +1,34 @@
|
|||||||
export function isObjEqual<Type>(obj1: Type, obj2: Type): boolean {
|
export function isObjEqual(a: object, b: object): boolean {
|
||||||
return obj1 && obj2 && Object.keys(obj1).length === Object.keys(obj2).length &&
|
// Сравнение примитивов и null/undefined
|
||||||
(Object.keys(obj1) as (keyof typeof obj1)[]).every(key => {
|
if (a === b) return true
|
||||||
return Object.prototype.hasOwnProperty.call(obj2, key) && obj1[key] === obj2[key]
|
if (!a || !b) return false
|
||||||
})
|
if (Object.keys(a).length !== Object.keys(b).length) return false
|
||||||
|
|
||||||
|
// Получаем все уникальные ключи из обоих объектов
|
||||||
|
const allKeys = new Set([
|
||||||
|
...Object.keys(a),
|
||||||
|
...Object.keys(b)
|
||||||
|
])
|
||||||
|
|
||||||
|
// Проверяем каждое свойство
|
||||||
|
for (const key of allKeys) {
|
||||||
|
const valA = a[key as keyof typeof a]
|
||||||
|
const valB = b[key as keyof typeof b]
|
||||||
|
|
||||||
|
// Если одно из значений undefined - объекты разные
|
||||||
|
if (valA === undefined || valB === undefined) return false
|
||||||
|
|
||||||
|
// Рекурсивное сравнение для вложенных объектов
|
||||||
|
if (typeof valA === 'object' && typeof valB === 'object') {
|
||||||
|
if (!isObjEqual(valA, valB)) return false
|
||||||
|
}
|
||||||
|
// Сравнение примитивов
|
||||||
|
else if (!Object.is(valA, valB)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
export function parseIntString (s: string | string[] | undefined) :number | null {
|
export function parseIntString (s: string | string[] | undefined) :number | null {
|
||||||
|
|||||||
18
src/boot/telegram-boot.ts
Normal file
18
src/boot/telegram-boot.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import type { BootParams } from '@quasar/app'
|
||||||
|
|
||||||
|
export default ({ app }: BootParams) => {
|
||||||
|
|
||||||
|
// Инициализация Telegram WebApp
|
||||||
|
if (window.Telegram?.WebApp) {
|
||||||
|
const webApp = window.Telegram.WebApp
|
||||||
|
// Помечаем приложение как готовое
|
||||||
|
webApp.ready()
|
||||||
|
// window.Telegram.WebApp.requestFullscreen()
|
||||||
|
// Опционально: сохраняем объект в Vue-приложение для глобального доступа
|
||||||
|
webApp.SettingsButton.isVisible = true
|
||||||
|
// webApp.BackButton.isVisible = true
|
||||||
|
app.config.globalProperties.$tg = webApp
|
||||||
|
// Для TypeScript: объявляем тип для инжекции
|
||||||
|
app.provide('tg', webApp)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,9 +15,10 @@
|
|||||||
<q-input
|
<q-input
|
||||||
v-model="login"
|
v-model="login"
|
||||||
dense
|
dense
|
||||||
|
filled
|
||||||
:label = "$t('account_helper__email')"
|
:label = "$t('account_helper__email')"
|
||||||
/>
|
/>
|
||||||
|
<div class="q-pt-md text-red">{{$t('account_helper__code_error')}}</div>
|
||||||
<q-stepper-navigation>
|
<q-stepper-navigation>
|
||||||
<q-btn @click="step = 2" color="primary" :label="$t('continue')" />
|
<q-btn @click="step = 2" color="primary" :label="$t('continue')" />
|
||||||
</q-stepper-navigation>
|
</q-stepper-navigation>
|
||||||
@@ -28,10 +29,11 @@
|
|||||||
:title="$t('account_helper__confirm_email')"
|
:title="$t('account_helper__confirm_email')"
|
||||||
:done="step > 2"
|
:done="step > 2"
|
||||||
>
|
>
|
||||||
{{$t('account_helper__confirm_email_messege')}}
|
<div class="q-pb-md">{{$t('account_helper__confirm_email_message')}}</div>
|
||||||
<q-input
|
<q-input
|
||||||
v-model="code"
|
v-model="code"
|
||||||
dense
|
dense
|
||||||
|
filled
|
||||||
:label = "$t('account_helper__code')"
|
:label = "$t('account_helper__code')"
|
||||||
/>
|
/>
|
||||||
<q-stepper-navigation>
|
<q-stepper-navigation>
|
||||||
@@ -47,7 +49,8 @@
|
|||||||
<q-input
|
<q-input
|
||||||
v-model="password"
|
v-model="password"
|
||||||
dense
|
dense
|
||||||
:label = "$t('account_helper_password')"
|
filled
|
||||||
|
:label = "$t('account_helper__password')"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<q-stepper-navigation>
|
<q-stepper-navigation>
|
||||||
@@ -73,7 +76,7 @@
|
|||||||
}>()
|
}>()
|
||||||
|
|
||||||
const step = ref<number>(1)
|
const step = ref<number>(1)
|
||||||
const login = ref<string>(props.email ? props.email : '')
|
const login = ref<string>(props.email || '')
|
||||||
const code = ref<string>('')
|
const code = ref<string>('')
|
||||||
const password = ref<string>('')
|
const password = ref<string>('')
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
filled
|
filled
|
||||||
class = "q-mt-md w100"
|
class = "q-mt-md w100"
|
||||||
:label = "input.label ? $t(input.label) : void 0"
|
:label = "input.label ? $t(input.label) : void 0"
|
||||||
|
:rules="input.val === 'name' ? [rules[input.val]] : []"
|
||||||
>
|
>
|
||||||
<template #prepend>
|
<template #prepend>
|
||||||
<q-icon v-if="input.icon" :name="input.icon"/>
|
<q-icon v-if="input.icon" :name="input.icon"/>
|
||||||
@@ -20,7 +21,10 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { watch, computed } from 'vue'
|
||||||
import type { CompanyParams } from 'src/types'
|
import type { CompanyParams } from 'src/types'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
const { t }= useI18n()
|
||||||
|
|
||||||
const modelValue = defineModel<CompanyParams>({
|
const modelValue = defineModel<CompanyParams>({
|
||||||
required: false,
|
required: false,
|
||||||
@@ -35,6 +39,25 @@
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits(['valid'])
|
||||||
|
const rulesErrorMessage = {
|
||||||
|
name: t('company_card__error_name')
|
||||||
|
}
|
||||||
|
|
||||||
|
const rules = {
|
||||||
|
name: (val :CompanyParams['name']) => !!val?.trim() || rulesErrorMessage['name']
|
||||||
|
}
|
||||||
|
|
||||||
|
const isValid = computed(() => {
|
||||||
|
const checkName = rules.name(modelValue.value.name)
|
||||||
|
return { name: checkName && (checkName !== rulesErrorMessage['name']) }
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(isValid, (newVal) => {
|
||||||
|
const allValid = Object.values(newVal).every(v => v)
|
||||||
|
emit('valid', allValid)
|
||||||
|
}, { immediate: true})
|
||||||
|
|
||||||
interface TextInput {
|
interface TextInput {
|
||||||
id: number
|
id: number
|
||||||
label?: string
|
label?: string
|
||||||
|
|||||||
@@ -68,7 +68,7 @@
|
|||||||
</g>
|
</g>
|
||||||
</svg>
|
</svg>
|
||||||
|
|
||||||
<span class="text-h4 text-white q-pa-0">
|
<span class="text-h4 q-pa-0" style="color: var(--logo-color-bg-white);">
|
||||||
projects
|
projects
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
@@ -99,7 +99,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.iconcolor {
|
.iconcolor {
|
||||||
--icon-color: white;
|
--icon-color: var(--logo-color-bg-white);
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes blink {
|
@keyframes blink {
|
||||||
|
|||||||
@@ -64,49 +64,47 @@
|
|||||||
</q-list>
|
</q-list>
|
||||||
|
|
||||||
</pn-scroll-list>
|
</pn-scroll-list>
|
||||||
|
|
||||||
<q-page-sticky
|
<q-page-sticky
|
||||||
position="bottom-right"
|
position="bottom-right"
|
||||||
:offset="[18, 18]"
|
:offset="[18, 18]"
|
||||||
:style="{ zIndex: !showOverlay ? 'inherit' : '5100 !important' }"
|
:style="{ zIndex: !showOverlay ? 'inherit' : '5100 !important' }"
|
||||||
>
|
>
|
||||||
<q-fab
|
<q-fab
|
||||||
icon="add"
|
v-if="showFab"
|
||||||
color="brand"
|
icon="add"
|
||||||
direction="up"
|
color="brand"
|
||||||
vertical-actions-align="right"
|
direction="up"
|
||||||
@click="showOverlay = !showOverlay;"
|
vertical-actions-align="right"
|
||||||
>
|
@click="showOverlay = !showOverlay"
|
||||||
<q-fab-action
|
>
|
||||||
v-for="item in fabMenu"
|
<q-fab-action
|
||||||
:key="item.id"
|
v-for="item in fabMenu"
|
||||||
square
|
:key="item.id"
|
||||||
clickable
|
square
|
||||||
v-ripple
|
clickable
|
||||||
class="bg-white change-fab-action"
|
v-ripple
|
||||||
>
|
class="bg-white change-fab-action"
|
||||||
<template #icon>
|
>
|
||||||
<q-item class="q-pa-xs w100">
|
<template #icon>
|
||||||
<q-item-section avatar class="items-center">
|
<q-item class="q-pa-xs w100">
|
||||||
<q-avatar color="brand" rounded text-color="white" :icon="item.icon" />
|
<q-item-section avatar class="items-center">
|
||||||
</q-item-section>
|
<q-avatar color="brand" rounded text-color="white" :icon="item.icon" />
|
||||||
|
</q-item-section>
|
||||||
|
|
||||||
<q-item-section class="items-start">
|
<q-item-section class="items-start">
|
||||||
<q-item-label class="fab-action-item">
|
<q-item-label class="fab-action-item">
|
||||||
{{ $t(item.name) }}
|
{{ $t(item.name) }}
|
||||||
</q-item-label>
|
</q-item-label>
|
||||||
<q-item-label caption class="fab-action-item">
|
<q-item-label caption class="fab-action-item">
|
||||||
{{ $t(item.description) }}
|
{{ $t(item.description) }}
|
||||||
</q-item-label>
|
</q-item-label>
|
||||||
|
|
||||||
</q-item-section>
|
</q-item-section>
|
||||||
</q-item>
|
</q-item>
|
||||||
</template>
|
</template>
|
||||||
</q-fab-action>
|
</q-fab-action>
|
||||||
|
</q-fab>
|
||||||
</q-fab>
|
|
||||||
</q-page-sticky>
|
</q-page-sticky>
|
||||||
|
|
||||||
<pn-overlay v-if="showOverlay"/>
|
<pn-overlay v-if="showOverlay"/>
|
||||||
</div>
|
</div>
|
||||||
<q-dialog v-model="showDialogDeleteChat" @before-hide="onDialogBeforeHide()">
|
<q-dialog v-model="showDialogDeleteChat" @before-hide="onDialogBeforeHide()">
|
||||||
@@ -140,7 +138,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed } from 'vue'
|
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||||
import { useChatsStore } from 'stores/chats'
|
import { useChatsStore } from 'stores/chats'
|
||||||
|
|
||||||
const search = ref('')
|
const search = ref('')
|
||||||
@@ -201,6 +199,16 @@
|
|||||||
}
|
}
|
||||||
currentSlideEvent.value = null
|
currentSlideEvent.value = null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const showFab = ref(false)
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
setTimeout(() => showFab.value = true, 500)
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
showFab.value = false
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
@@ -54,16 +54,16 @@
|
|||||||
</q-slide-item>
|
</q-slide-item>
|
||||||
</q-list>
|
</q-list>
|
||||||
</pn-scroll-list>
|
</pn-scroll-list>
|
||||||
|
|
||||||
<q-page-sticky
|
<q-page-sticky
|
||||||
position="bottom-right"
|
position="bottom-right"
|
||||||
:offset="[18, 18]"
|
:offset="[18, 18]"
|
||||||
>
|
>
|
||||||
<q-btn
|
<q-btn
|
||||||
fab
|
fab
|
||||||
icon="add"
|
icon="add"
|
||||||
color="brand"
|
color="brand"
|
||||||
@click="createCompany()"
|
@click="createCompany()"
|
||||||
|
v-if="showFab"
|
||||||
/>
|
/>
|
||||||
</q-page-sticky>
|
</q-page-sticky>
|
||||||
</div>
|
</div>
|
||||||
@@ -98,16 +98,19 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from 'vue'
|
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter, useRoute } from 'vue-router'
|
||||||
import { useCompaniesStore } from 'stores/companies'
|
import { useCompaniesStore } from 'stores/companies'
|
||||||
|
import { parseIntString } from 'boot/helpers'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
const route = useRoute()
|
||||||
const companiesStore = useCompaniesStore()
|
const companiesStore = useCompaniesStore()
|
||||||
const showDialogDeleteCompany = ref<boolean>(false)
|
const showDialogDeleteCompany = ref<boolean>(false)
|
||||||
const deleteCompanyId = ref<number | undefined>(undefined)
|
const deleteCompanyId = ref<number | undefined>(undefined)
|
||||||
const currentSlideEvent = ref<SlideEvent | null>(null)
|
const currentSlideEvent = ref<SlideEvent | null>(null)
|
||||||
const closedByUserAction = ref(false)
|
const closedByUserAction = ref(false)
|
||||||
|
const projectId = computed(() => parseIntString(route.params.id))
|
||||||
|
|
||||||
interface SlideEvent {
|
interface SlideEvent {
|
||||||
reset: () => void
|
reset: () => void
|
||||||
@@ -120,11 +123,11 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function goCompanyInfo (id :number) {
|
async function goCompanyInfo (id :number) {
|
||||||
await router.push({ name: 'company_info', params: { id }})
|
await router.push({ name: 'company_info', params: { id: projectId.value, companyId: id }})
|
||||||
}
|
}
|
||||||
|
|
||||||
async function createCompany () {
|
async function createCompany () {
|
||||||
await router.push({ name: 'create_company' })
|
await router.push({ name: 'add_company' })
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleSlide (event: SlideEvent, id: number) {
|
function handleSlide (event: SlideEvent, id: number) {
|
||||||
@@ -137,40 +140,38 @@
|
|||||||
if (!closedByUserAction.value) {
|
if (!closedByUserAction.value) {
|
||||||
onCancel()
|
onCancel()
|
||||||
}
|
}
|
||||||
closedByUserAction.value = false
|
closedByUserAction.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
function onCancel() {
|
function onCancel() {
|
||||||
closedByUserAction.value = true
|
closedByUserAction.value = true
|
||||||
if (currentSlideEvent.value) {
|
if (currentSlideEvent.value) {
|
||||||
currentSlideEvent.value.reset()
|
currentSlideEvent.value.reset()
|
||||||
|
currentSlideEvent.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onConfirm() {
|
||||||
|
closedByUserAction.value = true
|
||||||
|
if (deleteCompanyId.value) {
|
||||||
|
companiesStore.deleteCompany(deleteCompanyId.value)
|
||||||
|
}
|
||||||
currentSlideEvent.value = null
|
currentSlideEvent.value = null
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
function onConfirm() {
|
const showFab = ref(false)
|
||||||
closedByUserAction.value = true
|
|
||||||
if (deleteCompanyId.value) {
|
onMounted(() => {
|
||||||
companiesStore.deleteCompany(deleteCompanyId.value)
|
setTimeout(() => showFab.value = true, 500)
|
||||||
}
|
})
|
||||||
currentSlideEvent.value = null
|
|
||||||
}
|
onUnmounted(() => {
|
||||||
|
showFab.value = false
|
||||||
|
})
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.change-fab-action .q-fab__label--internal {
|
|
||||||
max-height: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.change-fab-action {
|
|
||||||
width: calc(100vw - 48px) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fab-action-item {
|
|
||||||
text-wrap: auto !important;
|
|
||||||
text-align: left;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* fix mini border after slide */
|
/* fix mini border after slide */
|
||||||
:deep(.q-slide-item__right)
|
:deep(.q-slide-item__right)
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
<div
|
<div
|
||||||
id="project-info"
|
id="project-info"
|
||||||
:style="{ height: headerHeight + 'px' }"
|
:style="{ height: headerHeight + 'px' }"
|
||||||
class="flex row items-center justify-between no-wrap q-py-sm w100"
|
class="flex row items-center justify-between no-wrap q-my-sm w100"
|
||||||
style="overflow: hidden; transition: height 0.3s ease-in-out;"
|
style="overflow: hidden; transition: height 0.3s ease-in-out;"
|
||||||
>
|
>
|
||||||
<div class="ellipsis overflow-hidden">
|
<div class="ellipsis overflow-hidden">
|
||||||
@@ -15,7 +15,7 @@
|
|||||||
<div
|
<div
|
||||||
v-if="!expandProjectInfo"
|
v-if="!expandProjectInfo"
|
||||||
@click="toggleExpand"
|
@click="toggleExpand"
|
||||||
class="text-h6 ellipsis no-wrap w100 cursor-pointer"
|
class="text-h6 ellipsis no-wrap w100"
|
||||||
key="compact"
|
key="compact"
|
||||||
>
|
>
|
||||||
{{project.name}}
|
{{project.name}}
|
||||||
@@ -27,10 +27,8 @@
|
|||||||
@click="toggleExpand"
|
@click="toggleExpand"
|
||||||
key="expanded"
|
key="expanded"
|
||||||
>
|
>
|
||||||
<div class="q-focus-helper"></div>
|
|
||||||
|
|
||||||
<q-avatar rounded>
|
<q-avatar rounded>
|
||||||
<q-img v-if="project.logo" :src="project.logo" fit="cover"/>
|
<q-img v-if="project.logo" :src="project.logo" fit="cover" style="height: 100%;"/>
|
||||||
<pn-auto-avatar v-else :name="project.name"/>
|
<pn-auto-avatar v-else :name="project.name"/>
|
||||||
</q-avatar>
|
</q-avatar>
|
||||||
|
|
||||||
@@ -69,14 +67,24 @@
|
|||||||
</q-menu>
|
</q-menu>
|
||||||
</q-btn>
|
</q-btn>
|
||||||
</div>
|
</div>
|
||||||
<q-dialog v-model="showDialogDeleteProject">
|
<q-dialog v-model="showDialog">
|
||||||
<q-card class="q-pa-none q-ma-none">
|
<q-card class="q-pa-none q-ma-none">
|
||||||
<q-card-section align="center">
|
<q-card-section align="center">
|
||||||
<div class="text-h6 text-negative ">{{ $t('project__delete_warning') }}</div>
|
<div class="text-h6 text-negative ">
|
||||||
|
{{ $t(
|
||||||
|
dialogType === 'archive'
|
||||||
|
? 'project__archive_warning'
|
||||||
|
: 'project__delete_warning'
|
||||||
|
)}}
|
||||||
|
</div>
|
||||||
</q-card-section>
|
</q-card-section>
|
||||||
|
|
||||||
<q-card-section class="q-pt-none" align="center">
|
<q-card-section class="q-pt-none" align="center">
|
||||||
{{ $t('project__delete_warning_message') }}
|
{{ $t(
|
||||||
|
dialogType === 'archive'
|
||||||
|
? 'project__archive_warning_message'
|
||||||
|
: 'project__delete_warning_message'
|
||||||
|
)}}
|
||||||
</q-card-section>
|
</q-card-section>
|
||||||
|
|
||||||
<q-card-actions align="center">
|
<q-card-actions align="center">
|
||||||
@@ -88,10 +96,14 @@
|
|||||||
/>
|
/>
|
||||||
<q-btn
|
<q-btn
|
||||||
flat
|
flat
|
||||||
:label="$t('continue')"
|
:label="$t(
|
||||||
color="primary"
|
dialogType === 'archive'
|
||||||
|
? 'project__archive'
|
||||||
|
: 'project__delete'
|
||||||
|
)"
|
||||||
|
color="negative"
|
||||||
v-close-popup
|
v-close-popup
|
||||||
@click="deleteProject()"
|
@click="dialogType === 'archive' ? archiveProject() : deleteProject()"
|
||||||
/>
|
/>
|
||||||
</q-card-actions>
|
</q-card-actions>
|
||||||
</q-card>
|
</q-card>
|
||||||
@@ -109,17 +121,16 @@
|
|||||||
const projectsStore = useProjectsStore()
|
const projectsStore = useProjectsStore()
|
||||||
|
|
||||||
const expandProjectInfo = ref<boolean>(false)
|
const expandProjectInfo = ref<boolean>(false)
|
||||||
const showDialogDeleteProject = ref<boolean>(false)
|
const showDialog = ref<boolean>(false)
|
||||||
const showDialogArchiveProject = ref<boolean>(false)
|
const dialogType = ref<null | 'archive' | 'delete'>(null)
|
||||||
|
|
||||||
const headerHeight = ref<number>(0)
|
const headerHeight = ref<number>(0)
|
||||||
|
|
||||||
|
|
||||||
const menuItems = [
|
const menuItems = [
|
||||||
{ id: 1, title: 'project__edit', icon: 'mdi-square-edit-outline', iconColor: '', func: editProject },
|
{ id: 1, title: 'project__edit', icon: 'mdi-square-edit-outline', iconColor: '', func: editProject },
|
||||||
{ id: 2, title: 'project__backup', icon: 'mdi-content-save-outline', iconColor: '', func: () => {} },
|
// { id: 2, title: 'project__backup', icon: 'mdi-content-save-outline', iconColor: '', func: () => {} },
|
||||||
{ id: 3, title: 'project__archive', icon: 'mdi-archive-outline', iconColor: '', func: () => { showDialogArchiveProject.value = true }},
|
{ id: 3, title: 'project__archive', icon: 'mdi-archive-outline', iconColor: '', func: () => { showDialog.value = true; dialogType.value = 'archive' }},
|
||||||
{ id: 4, title: 'project__delete', icon: 'mdi-trash-can-outline', iconColor: 'red', func: () => { showDialogDeleteProject.value = true }},
|
{ id: 4, title: 'project__delete', icon: 'mdi-trash-can-outline', iconColor: 'red', func: () => { showDialog.value = true; dialogType.value = 'delete' }},
|
||||||
]
|
]
|
||||||
|
|
||||||
const projectId = computed(() => parseIntString(route.params.id))
|
const projectId = computed(() => parseIntString(route.params.id))
|
||||||
@@ -156,6 +167,10 @@ async function editProject () {
|
|||||||
await router.push({ name: 'project_info' })
|
await router.push({ name: 'project_info' })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function archiveProject () {
|
||||||
|
console.log('archive project')
|
||||||
|
}
|
||||||
|
|
||||||
function deleteProject () {
|
function deleteProject () {
|
||||||
console.log('delete project')
|
console.log('delete project')
|
||||||
}
|
}
|
||||||
@@ -175,6 +190,10 @@ function onResize (size :sizeParams) {
|
|||||||
|
|
||||||
watch(projectId, loadProjectData)
|
watch(projectId, loadProjectData)
|
||||||
|
|
||||||
|
watch(showDialog, () => {
|
||||||
|
if (showDialog.value === false) dialogType.value = null
|
||||||
|
})
|
||||||
|
|
||||||
onMounted(() => loadProjectData())
|
onMounted(() => loadProjectData())
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,20 +1,20 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="flex column">
|
<div class="flex column items-center q-pa-lg">
|
||||||
<div class="flex column items-center col-grow q-pa-lg">
|
<pn-image-selector
|
||||||
<pn-image-selector
|
v-model="modelValue.logo"
|
||||||
:size="100"
|
:size="100"
|
||||||
:iconsize="80"
|
:iconsize="80"
|
||||||
class="q-pb-lg"
|
class="q-pb-lg"
|
||||||
v-model="modelValue.logo"
|
/>
|
||||||
/>
|
<div class="q-gutter-y-lg w100">
|
||||||
|
|
||||||
<q-input
|
<q-input
|
||||||
v-model="modelValue.name"
|
v-model="modelValue.name"
|
||||||
|
no-error-icon
|
||||||
dense
|
dense
|
||||||
filled
|
filled
|
||||||
class="q-mt-sm w100"
|
class = "w100 fix-bottom-padding"
|
||||||
:label="$t('project_card__project_name')"
|
:label="$t('project_card__project_name')"
|
||||||
:rules="[val => !!val || $t('validation.required')]"
|
:rules="[rules.name]"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<q-input
|
<q-input
|
||||||
@@ -22,7 +22,7 @@
|
|||||||
dense
|
dense
|
||||||
filled
|
filled
|
||||||
autogrow
|
autogrow
|
||||||
class="q-my-lg w100"
|
class="w100"
|
||||||
:label="$t('project_card__project_description')"
|
:label="$t('project_card__project_description')"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -30,6 +30,7 @@
|
|||||||
v-if="modelValue.logo"
|
v-if="modelValue.logo"
|
||||||
v-model="modelValue.logo_as_bg"
|
v-model="modelValue.logo_as_bg"
|
||||||
class="w100"
|
class="w100"
|
||||||
|
dense
|
||||||
>
|
>
|
||||||
{{ $t('project_card__image_use_as_background_chats') }}
|
{{ $t('project_card__image_use_as_background_chats') }}
|
||||||
</q-checkbox>
|
</q-checkbox>
|
||||||
@@ -39,9 +40,35 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { watch, computed } from 'vue'
|
||||||
import type { ProjectParams } from 'src/types'
|
import type { ProjectParams } from 'src/types'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
const { t }= useI18n()
|
||||||
|
|
||||||
const modelValue = defineModel<ProjectParams>({ required: true })
|
const modelValue = defineModel<ProjectParams>({ required: true })
|
||||||
|
const emit = defineEmits(['valid'])
|
||||||
|
const rulesErrorMessage = {
|
||||||
|
name: t('project_card__error_name')
|
||||||
|
}
|
||||||
|
|
||||||
|
const rules = {
|
||||||
|
name: (val :ProjectParams['name']) => !!val?.trim() || rulesErrorMessage['name']
|
||||||
|
}
|
||||||
|
|
||||||
|
const isValid = computed(() => {
|
||||||
|
const checkName = rules.name(modelValue.value.name)
|
||||||
|
return { name: checkName && (checkName !== rulesErrorMessage['name']) }
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(isValid, (newVal) => {
|
||||||
|
const allValid = Object.values(newVal).every(v => v)
|
||||||
|
emit('valid', allValid)
|
||||||
|
}, { immediate: true })
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
.q-field--with-bottom.fix-bottom-padding {
|
||||||
|
padding-bottom: 0 !important
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -22,6 +22,13 @@ $base-height: 100;
|
|||||||
:root {
|
:root {
|
||||||
--body-width: 600px;
|
--body-width: 600px;
|
||||||
--top-raduis: 12px;
|
--top-raduis: 12px;
|
||||||
|
--logo-color-bg-white: grey;
|
||||||
|
--dynamic-font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#q-app {
|
||||||
|
padding-bottom: env(safe-area-inset-bottom, 0);
|
||||||
|
padding-bottom: constant(safe-area-inset-bottom, 0); // Для старых iOS
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
|
|||||||
@@ -26,3 +26,4 @@ $warning : #F2C037;
|
|||||||
|
|
||||||
$lightgrey : #DCDCDC;
|
$lightgrey : #DCDCDC;
|
||||||
|
|
||||||
|
$body-font-size: var(--dynamic-font-size)
|
||||||
@@ -1 +1 @@
|
|||||||
export default { EN: 'EN', RU: 'RU', continue: 'Continue', back: 'Back', month: 'month', months: 'months', login__email: 'E-mail', login__password: 'Password', login__forgot_password: 'Forgot Password?', login__sign_in: 'Log in', login__incorrect_login_data: 'User data not found. Edit your auth details before continuing', login__or_continue_as: 'or continue as', login__terms_of_use: 'Terms of use', login__accept_terms_of_use: 'I accept the', login__register: 'Create account', login__registration_message_ok: 'We sent message with instructions to your email', login__registration_message_error: 'Error', login__licensing_agreement: 'Licensing agreement', login__have_account: 'Already have an accont?', login__forgot_password_message: 'Enter your e-mail to recover your password. We will send instructions to the specified email address. If you havent received the email, check the Spam folder.', login__forgot_password_message_ok: 'We sent message with instructions to your email', login__forgot_password_message_error: 'Error', user__logout: 'Logout', projects__projects: 'Projects', projects__show_archive: 'Show archive', projects__hide_archive: 'Hide archive', projects__restore_archive_warning: 'Attention!', projects__restore_archive_warning_message: 'To restore a project from an archive, you must manually attach chats to it.', project__chats: 'Chats', project__persons: 'Persons', project__companies: 'Companies', project__edit: 'Edit', project__backup: 'Backup', project__archive: 'Archive', project__delete: 'Delete', project_chats__search: 'Search', project_chats__send_chat: 'Request for attach chat', project_chats__send_chat_description: 'Provide instructions to the chat admin', project_chats__attach_chat: 'Attach chat', project_chats__attach_chat_description: 'Requires chat administrator privileges', project_chat__delete_warning: 'Warning!', project_chat__delete_warning_message: 'Chat tracking will be discontinued. If necessary, the cat can be attached again.', project_card__add_project: 'Add project', project_card__project_name: 'Name', project_card__project_description: 'Desription', project_card__btn_accept: 'Accept', project_card__btn_back: 'Back', forgot_password__password_recovery: 'Password recovery', forgot_password__enter_email: 'Enter account e-mail', forgot_password__email: 'E-mail', forgot_password__confirm_email: 'Confirm e-mail', forgot_password__confirm_email_messege: 'Enter the Code from e-mail to continue recover your password. If you haven\'t received an e-mail with the Code, check the Spam folder.', forgot_password__code: 'Code', forgot_password__create_new_password: 'Set new password', forgot_password__password: 'Password', forgot_password__finish: 'Create', account__user_settings: 'User settings', account__your_company: 'Your company', account__change_auth: 'Change authorization method', account__change_auth_message_1: 'In case of corporate use, it is recommended to log in with a username and password.', account__change_auth_message_2: 'After creating a user, all data from the Telegram account will be transferred to the new account.', account__change_auth_btn: 'Create system account', account__change_auth_warning: 'WARNING!', account__change_auth_warning_message: 'Reverse data transfer is not possible.', account__chats: 'Chats', account__chats_active: 'Active', account__chats_archive: 'Archive', account__chats_free: 'Free', account__chats_total: 'Total', account__subscribe: 'Subscribe', account__subscribe_info: 'With a subscription, you can attach more chats. Archived chats are not counted.', account__subscribe_current_balance: 'Current balance', account__subscribe_about: 'about', account__subscribe_select_payment_1: 'You can pay for your subscription using ', account__subscribe_select_payment_2: 'Telegram stars', company__mask: 'Company cloacking', mask__title_table: 'Ignore cloaking', mask__title_table2: '(exclusion list)', mask__help_title: 'Cloacking', mask__help_message: 'It is possible to cloacking a company by representing its personnel as your own to companies other than those on the exclusion list.', company_info__title_card: 'Company card', company_info__name: 'Name', company_info__description: 'Description', company_info__persons: 'Persons', company_create__title_card: 'Add company', project_persons__search: 'Search', person_card__title: 'Person card', person_card__name: 'Name', person_card__company: 'Company name', person_card__department: 'Department', person_card__role: 'Role' }
|
export default { EN: 'EN', RU: 'RU', continue: 'Continue', back: 'Back', month: 'month', months: 'months', slogan: 'Work together - it\'s magic!', login__email: 'E-mail', login__password: 'Password', login__forgot_password: 'Forgot Password?', login__sign_in: 'Log in', login__incorrect_login_data: 'User data not found. Edit your auth details before continuing', login__or_continue_as: 'or continue as', login__terms_of_use: 'Terms of use', login__accept_terms_of_use: 'I accept the', login__register: 'Create account', login__registration_message_error: 'Error', login__licensing_agreement: 'Licensing agreement', login__have_account: 'Already have an accont?', user__logout: 'Logout', projects__projects: 'Projects', projects__show_archive: 'Show archive', projects__hide_archive: 'Hide archive', projects__restore_archive_warning: 'Attention!', projects__restore_archive_warning_message: 'To restore a project from an archive, you must manually attach chats to it.', project__chats: 'Chats', project__persons: 'Persons', project__companies: 'Companies', project__edit: 'Edit', project__backup: 'Backup', project__archive: 'Archive', project__archive_warning: 'Are you sure?', project__archive_warning_message: 'Chat tracking in the project will be disabled after moving to the archive.', project__delete: 'Delete', project__delete_warning: 'Warning!', project__delete_warning_message: 'All project data will be removed. This action cannot be undone.', project_chats__search: 'Search', project_chats__send_chat: 'Request for attach chat', project_chats__send_chat_description: 'Provide instructions to the chat admin', project_chats__attach_chat: 'Attach chat', project_chats__attach_chat_description: 'Requires chat administrator privileges', project_chat__delete_warning: 'Warning!', project_chat__delete_warning_message: 'Chat tracking will be discontinued. If necessary, the cat can be attached again.', project_card__project_card: 'Project card', project_card__add_project: 'Add project', project_card__project_name: 'Name', project_card__project_description: 'Description', project_card__btn_accept: 'Accept', project_card__btn_back: 'Back', project_card__image_use_as_background_chats: 'logo as background for chats', project_card__error_name: 'Field is required', forgot_password__password_recovery: 'Password recovery', account_helper__enter_email: 'Enter account e-mail', account_helper__email: 'E-mail', account_helper__confirm_email: 'Confirm e-mail', account_helper__confirm_email_message: 'Enter the Code from e-mail to continue recover your password. If you haven\'t received an e-mail with the Code, check the Spam folder.', account_helper__code: 'Code', account_helper__code_error: 'Incorrect code. Ensure your e-mail is correct and try again.', account_helper__set_password: 'Set password', account_helper__password: 'Password', account_helper__finish: 'Finish', account_helper__finish_after_message: 'Done!', account__user_settings: 'User settings', account__your_company: 'Your company', account__change_auth: 'Change authorization method', account__change_auth_message_1: 'In case of corporate use, it is recommended to log in with a username and password.', account__change_auth_message_2: 'After creating a user, all data from the Telegram account will be transferred to the new account.', account__change_auth_btn: 'Create system account', account__change_auth_warning: 'WARNING!', account__change_auth_warning_message: 'Reverse data transfer is not possible.', account__chats: 'Chats', account__chats_active: 'Active', account__chats_archive: 'Archive', account__chats_free: 'Free', account__chats_total: 'Total', account__subscribe: 'Subscribe', account__subscribe_info: 'With a subscription, you can attach more chats. Archived chats are not counted.', account__subscribe_current_balance: 'Current balance', account__subscribe_about: 'about', account__subscribe_select_payment_1: 'You can pay for your subscription using ', account__subscribe_select_payment_2: 'Telegram stars', company__mask: 'Company cloacking', mask__title_table: 'Ignore cloaking', mask__title_table2: '(exclusion list)', mask__help_title: 'Cloacking', mask__help_message: 'It is possible to cloacking a company by representing its personnel as your own to companies other than those on the exclusion list.', company_info__title_card: 'Company card', company_info__name: 'Name', company_info__description: 'Description', company_info__persons: 'Persons', company_create__title_card: 'Add company', project_persons__search: 'Search', person_card__title: 'Person card', person_card__name: 'Name', person_card__company: 'Company name', person_card__department: 'Department', person_card__role: 'Role', settings__title: 'Settings', settings__language: 'Language', settings__font_size: 'Font size', terms__title: 'Terms of use' }
|
||||||
File diff suppressed because one or more lines are too long
@@ -1,32 +0,0 @@
|
|||||||
import { defineStore } from '#q-app/wrappers'
|
|
||||||
import { createPinia } from 'pinia'
|
|
||||||
|
|
||||||
/*
|
|
||||||
* When adding new properties to stores, you should also
|
|
||||||
* extend the `PiniaCustomProperties` interface.
|
|
||||||
* @see https://pinia.vuejs.org/core-concepts/plugins.html#typing-new-store-properties
|
|
||||||
*/
|
|
||||||
declare module 'pinia' {
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
|
|
||||||
export interface PiniaCustomProperties {
|
|
||||||
// add your custom properties here, if any
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/*
|
|
||||||
* If not building with SSR mode, you can
|
|
||||||
* directly export the Store instantiation;
|
|
||||||
*
|
|
||||||
* The function below can be async too; either use
|
|
||||||
* async/await or return a Promise which resolves
|
|
||||||
* with the Store instance.
|
|
||||||
*/
|
|
||||||
|
|
||||||
export default defineStore((/* { ssrContext } */) => {
|
|
||||||
const pinia = createPinia()
|
|
||||||
|
|
||||||
// You can add Pinia plugins here
|
|
||||||
// pinia.use(SomePiniaPlugin)
|
|
||||||
|
|
||||||
return pinia
|
|
||||||
})
|
|
||||||
@@ -4,8 +4,22 @@
|
|||||||
fit
|
fit
|
||||||
class="fit no-scroll bg-transparent"
|
class="fit no-scroll bg-transparent"
|
||||||
>
|
>
|
||||||
<q-drawer show-if-above side="left" class="drawer no-scroll" :width="drawerWidth" :breakpoint="bodyWidth"/>
|
<q-drawer
|
||||||
<q-drawer show-if-above side="right" class="drawer no-scroll" :width="drawerWidth" :breakpoint="bodyWidth"/>
|
v-if="existDrawer"
|
||||||
|
show-if-above
|
||||||
|
side="left"
|
||||||
|
class="drawer no-scroll"
|
||||||
|
:width="drawerWidth"
|
||||||
|
:breakpoint="bodyWidth"
|
||||||
|
/>
|
||||||
|
<q-drawer
|
||||||
|
v-if="existDrawer"
|
||||||
|
show-if-above
|
||||||
|
side="right"
|
||||||
|
class="drawer no-scroll"
|
||||||
|
:width="drawerWidth"
|
||||||
|
:breakpoint="bodyWidth"
|
||||||
|
/>
|
||||||
|
|
||||||
<q-page-container
|
<q-page-container
|
||||||
class="q-pa-none q-ma-none no-scroll bg-transparent page-width"
|
class="q-pa-none q-ma-none no-scroll bg-transparent page-width"
|
||||||
@@ -20,6 +34,8 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
import meshBackground from '../components/admin/meshBackground.vue'
|
import meshBackground from '../components/admin/meshBackground.vue'
|
||||||
|
|
||||||
|
const existDrawer = ref<boolean>(true)
|
||||||
function getCSSVar (varName: string) {
|
function getCSSVar (varName: string) {
|
||||||
const root = document.documentElement
|
const root = document.documentElement
|
||||||
return getComputedStyle(root).getPropertyValue(varName).trim()
|
return getComputedStyle(root).getPropertyValue(varName).trim()
|
||||||
@@ -30,6 +46,7 @@
|
|||||||
function onResize () {
|
function onResize () {
|
||||||
const clientWidth = document.documentElement.clientWidth;
|
const clientWidth = document.documentElement.clientWidth;
|
||||||
drawerWidth.value = (clientWidth - bodyWidth)/2
|
drawerWidth.value = (clientWidth - bodyWidth)/2
|
||||||
|
existDrawer.value = clientWidth > bodyWidth
|
||||||
}
|
}
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -3,43 +3,63 @@
|
|||||||
<template #title>
|
<template #title>
|
||||||
<div class="flex justify-between items-center text-white q-pa-sm w100">
|
<div class="flex justify-between items-center text-white q-pa-sm w100">
|
||||||
<div class="flex items-center justify-center row">
|
<div class="flex items-center justify-center row">
|
||||||
<q-avatar size="48px" class="q-mr-xs">
|
<q-avatar v-if="tgUser?.photo_url" size="48px" class="q-mr-xs">
|
||||||
<img src="https://cdn.quasar.dev/img/avatar2.jpg">
|
<q-img :src="tgUser.photo_url"/>
|
||||||
</q-avatar>
|
</q-avatar>
|
||||||
<div class="flex column">
|
<div class="flex column">
|
||||||
<span class="q-ml-xs text-h5">
|
<span class="q-ml-xs text-h5">
|
||||||
Alex mart
|
{{
|
||||||
|
tgUser?.first_name +
|
||||||
|
(tgUser?.first_name && tgUser?.last_name ? ' ' : '') +
|
||||||
|
tgUser?.last_name
|
||||||
|
}}
|
||||||
</span>
|
</span>
|
||||||
<span class="q-ml-xs text-caption">
|
<span class="q-ml-xs text-caption">
|
||||||
@alexmart80
|
{{ tgUser?.username }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
<q-btn
|
|
||||||
@click = "goProjects()"
|
|
||||||
flat round
|
|
||||||
icon="mdi-check"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<pn-scroll-list>
|
<pn-scroll-list>
|
||||||
<div class="w100 flex column items-center q-pb-md q-pt-sm q-px-md" >
|
<div class="w100 flex column items-center q-pb-md q-pt-sm q-px-md" >
|
||||||
<div class="text-caption text-bold self-start q-pl-sm q-pb-sm">
|
<div class="flex w100 justify-between items-center q-pl-sm">
|
||||||
{{ $t('account__user_settings') }}
|
<div class="text-caption text-bold">
|
||||||
|
{{ $t('account__user_settings') }}</div>
|
||||||
|
<q-btn
|
||||||
|
@click = "goProjects()"
|
||||||
|
flat round
|
||||||
|
color="primary"
|
||||||
|
icon="mdi-check"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex w100">
|
<q-transition-group
|
||||||
<q-input
|
tag="div"
|
||||||
v-model="company"
|
class="flex w100 company-container"
|
||||||
dense
|
enter-active-class="animate__animated animate__fadeIn"
|
||||||
filled
|
leave-active-class="animate__animated animate__fadeOut"
|
||||||
class = "q-mb-md q-mr-md col-grow"
|
appear
|
||||||
:label = "$t('account__your_company')"
|
>
|
||||||
/>
|
<template v-if="company">
|
||||||
<pn-image-selector v-if="company" :size="40" :iconsize="40"/>
|
<pn-image-selector
|
||||||
|
key="image"
|
||||||
|
class="q-mr-sm company-logo"
|
||||||
|
:size="40"
|
||||||
|
:iconsize="40"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
</div>
|
<q-input
|
||||||
|
key="input"
|
||||||
|
v-model="company"
|
||||||
|
dense
|
||||||
|
filled
|
||||||
|
class="q-mb-md col-grow company-input"
|
||||||
|
:label="$t('account__your_company')"
|
||||||
|
:style="{ marginLeft: !company ? '0' : '48px', transition: 'all 0.3s' }"
|
||||||
|
/>
|
||||||
|
</q-transition-group>
|
||||||
<q-expansion-item
|
<q-expansion-item
|
||||||
dense
|
dense
|
||||||
id="warning"
|
id="warning"
|
||||||
@@ -78,7 +98,6 @@
|
|||||||
<div id="qty_chats" class="flex column q-pt-lg w100 q-pl-sm">
|
<div id="qty_chats" class="flex column q-pt-lg w100 q-pl-sm">
|
||||||
<div class="text-caption text-bold flex items-center">
|
<div class="text-caption text-bold flex items-center">
|
||||||
<span>{{ $t('account__chats') }}</span>
|
<span>{{ $t('account__chats') }}</span>
|
||||||
<q-icon name = "mdi-message-outline" class="q-ma-xs"/>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="flex row justify-between">
|
<div class="flex row justify-between">
|
||||||
<qty-chat-card
|
<qty-chat-card
|
||||||
@@ -95,54 +114,42 @@
|
|||||||
<div class="text-caption text-bold">
|
<div class="text-caption text-bold">
|
||||||
{{ $t('account__subscribe') }}
|
{{ $t('account__subscribe') }}
|
||||||
</div>
|
</div>
|
||||||
<div
|
|
||||||
class="bg-info q-pa-sm text-white"
|
<q-item class="q-pa-sm text-caption">
|
||||||
:style="{ borderRadius: '5px' }"
|
|
||||||
>
|
|
||||||
<q-item class="q-pa-none q-ma-none">
|
|
||||||
<q-item-section
|
<q-item-section
|
||||||
avatar
|
avatar
|
||||||
class="q-pr-none"
|
class="q-pr-none"
|
||||||
:style="{ minWidth: 'inherit !important' }"
|
:style="{ minWidth: 'inherit !important' }"
|
||||||
>
|
>
|
||||||
<q-icon name = "mdi-message-plus-outline" size="md"/>
|
<q-icon name = "mdi-crown-circle-outline" color="orange" size="md"/>
|
||||||
</q-item-section>
|
</q-item-section>
|
||||||
<q-item-section class="q-pl-sm">
|
<q-item-section class="q-pl-sm">
|
||||||
<span>{{ $t('account__subscribe_info') }}</span>
|
<span>{{ $t('account__subscribe_info') }}</span>
|
||||||
|
<span>{{ $t('account__subscribe_select_payment_1') }}</span>
|
||||||
|
<q-icon name = "mdi-star" class="text-orange" size="sm"/>
|
||||||
|
<span>{{ $t('account__subscribe_select_payment_2') }}</span>
|
||||||
</q-item-section>
|
</q-item-section>
|
||||||
</q-item>
|
</q-item>
|
||||||
</div>
|
|
||||||
|
|
||||||
|
|
||||||
<div class="flex w100 justify-between items-center no-wrap q-pt-sm">
|
<div class="flex w100 justify-between items-center no-wrap q-pt-sm">
|
||||||
<div class="flex column">
|
<div class="flex column">
|
||||||
<div>
|
<div>
|
||||||
{{ $t('account__subscribe_current_balance') }}
|
{{ $t('account__subscribe_current_balance') }}
|
||||||
</div>
|
</div>
|
||||||
<div class="text-caption text-grey">
|
|
||||||
{{ $t('account__subscribe_about') }} 3 {{ $t('months') }}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<div class="text-bold q-pa-sm text-h6">
|
<q-icon name = "mdi-crown-circle-outline" color="orange" size="sm"/>
|
||||||
|
<div class="text-bold q-pa-xs text-h6">
|
||||||
50
|
50
|
||||||
</div>
|
</div>
|
||||||
<span class="text-grey">
|
|
||||||
<q-icon name = "mdi-message-outline"/>
|
|
||||||
<q-icon name = "mdi-close"/>
|
|
||||||
<span>
|
|
||||||
{{ $t('month') }}
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="payment-selector">
|
<div id="payment-selector">
|
||||||
<div class="q-py-sm">
|
<div class="q-py-sm">
|
||||||
<span>{{ $t('account__subscribe_select_payment_1') }}</span>
|
|
||||||
<q-icon name = "mdi-star" class="text-orange" size="sm"/>
|
|
||||||
<span>{{ $t('account__subscribe_select_payment_2') }}</span>
|
|
||||||
</div>
|
</div>
|
||||||
<q-list>
|
<q-list>
|
||||||
<q-item
|
<q-item
|
||||||
@@ -199,13 +206,18 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from 'vue'
|
import { ref, inject } from 'vue'
|
||||||
import qtyChatCard from 'components/admin/account-page/qtyChatCard.vue'
|
import qtyChatCard from 'components/admin/account-page/qtyChatCard.vue'
|
||||||
import optionPayment from 'components/admin/account-page/optionPayment.vue'
|
import optionPayment from 'components/admin/account-page/optionPayment.vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
|
import { useAuthStore } from 'stores/auth'
|
||||||
|
import type { WebApp } from '@twa-dev/types'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
|
||||||
|
const tg = inject('tg') as WebApp
|
||||||
|
const tgUser = tg.initDataUnsafe.user
|
||||||
const company = ref<string>('')
|
const company = ref<string>('')
|
||||||
const showChangeAuthDialog = ref<boolean>(false)
|
const showChangeAuthDialog = ref<boolean>(false)
|
||||||
|
|
||||||
@@ -225,6 +237,7 @@
|
|||||||
|
|
||||||
async function change_auth () {
|
async function change_auth () {
|
||||||
console.log('update')
|
console.log('update')
|
||||||
|
console.log(authStore)
|
||||||
await router.push({ name: 'login' })
|
await router.push({ name: 'login' })
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -232,7 +245,6 @@
|
|||||||
await router.push({ name: 'projects' })
|
await router.push({ name: 'projects' })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss">
|
<style lang="scss">
|
||||||
@@ -241,6 +253,24 @@
|
|||||||
padding: 0 !important;
|
padding: 0 !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.company-container {
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
min-height: 60px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.company-logo {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
:deep(.animate__animated) {
|
||||||
|
--animate-duration: 0.4s;
|
||||||
|
animation-fill-mode: both;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -6,35 +6,69 @@
|
|||||||
{{$t('company_info__title_card')}}
|
{{$t('company_info__title_card')}}
|
||||||
</div>
|
</div>
|
||||||
<q-btn
|
<q-btn
|
||||||
v-if="!isObjEqual<Company | undefined>(companyFromStore, companyMod)"
|
v-if="isFormValid && isDirty()"
|
||||||
@click = "companiesStore.updateCompany(companyId, companyMod)"
|
@click = "updateCompany()"
|
||||||
flat round
|
flat round
|
||||||
icon="mdi-check"
|
icon="mdi-check"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<pn-scroll-list>
|
<pn-scroll-list>
|
||||||
<company-info-block v-model="companyMod"/>
|
<company-info-block
|
||||||
|
v-if="company"
|
||||||
|
v-model="company"
|
||||||
|
@valid="isFormValid = $event"
|
||||||
|
/>
|
||||||
<company-info-persons/>
|
<company-info-persons/>
|
||||||
</pn-scroll-list>
|
</pn-scroll-list>
|
||||||
</pn-page-card>
|
</pn-page-card>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from 'vue'
|
import { ref, onMounted } from 'vue'
|
||||||
import { useRoute } from 'vue-router'
|
import { useRouter, useRoute } from 'vue-router'
|
||||||
import companyInfoBlock from 'components/admin/companyInfoBlock.vue'
|
import companyInfoBlock from 'components/admin/companyInfoBlock.vue'
|
||||||
import companyInfoPersons from 'components/admin/companyInfoPersons.vue'
|
import companyInfoPersons from 'components/admin/companyInfoPersons.vue'
|
||||||
import { useCompaniesStore } from 'stores/companies'
|
import { useCompaniesStore } from 'stores/companies'
|
||||||
import type { Company } from 'src/types'
|
import type { Company } from 'src/types'
|
||||||
import { isObjEqual } from 'boot/helpers'
|
import { parseIntString, isObjEqual } from 'boot/helpers'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const companiesStore = useCompaniesStore()
|
const companiesStore = useCompaniesStore()
|
||||||
|
|
||||||
const companyId = Number(route.params.id)
|
const company = ref<Company>()
|
||||||
const companyFromStore = companiesStore.companyById(companyId)
|
const companyId = parseIntString(route.params.companyId)
|
||||||
const companyMod = ref({...(companyFromStore ? companyFromStore : <Company>{})})
|
|
||||||
|
const isFormValid = ref(false)
|
||||||
|
|
||||||
|
const originalCompany = ref<Company>({} as Company)
|
||||||
|
|
||||||
|
const isDirty = () => {
|
||||||
|
return company.value && !isObjEqual(originalCompany.value, company.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
if (companyId && companiesStore.companyById(companyId)) {
|
||||||
|
const initial = companiesStore.companyById(companyId)
|
||||||
|
|
||||||
|
company.value = { ...initial } as Company
|
||||||
|
originalCompany.value = JSON.parse(JSON.stringify(company.value))
|
||||||
|
} else {
|
||||||
|
await abort()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
function updateCompany () {
|
||||||
|
if (companyId && company.value) {
|
||||||
|
companiesStore.updateCompany(companyId, company.value)
|
||||||
|
router.back()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function abort () {
|
||||||
|
await router.replace({name: 'projects'})
|
||||||
|
}
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -2,10 +2,12 @@
|
|||||||
<pn-page-card>
|
<pn-page-card>
|
||||||
<template #title>
|
<template #title>
|
||||||
<div class="col-grow">
|
<div class="col-grow">
|
||||||
{{$t('create_account')}}
|
{{$t('login__register')}}
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<account-helper :type />
|
<pn-scroll-list>
|
||||||
|
<account-helper :type />
|
||||||
|
</pn-scroll-list>
|
||||||
</pn-page-card>
|
</pn-page-card>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
{{$t('project_card__add_project')}}
|
{{$t('project_card__add_project')}}
|
||||||
</div>
|
</div>
|
||||||
<q-btn
|
<q-btn
|
||||||
v-if="(Object.keys(project).length !== 0)"
|
v-if="isFormValid && isDirty"
|
||||||
@click = "addProject(project)"
|
@click = "addProject(project)"
|
||||||
flat round
|
flat round
|
||||||
icon="mdi-check"
|
icon="mdi-check"
|
||||||
@@ -14,13 +14,16 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<pn-scroll-list>
|
<pn-scroll-list>
|
||||||
<project-info-block v-model="project"/>
|
<project-info-block
|
||||||
|
v-model="project"
|
||||||
|
@valid="isFormValid = $event"
|
||||||
|
/>
|
||||||
</pn-scroll-list>
|
</pn-scroll-list>
|
||||||
</pn-page-card>
|
</pn-page-card>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from 'vue'
|
import { ref, computed } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import projectInfoBlock from 'components/admin/projectInfoBlock.vue'
|
import projectInfoBlock from 'components/admin/projectInfoBlock.vue'
|
||||||
import { useProjectsStore } from 'stores/projects'
|
import { useProjectsStore } from 'stores/projects'
|
||||||
@@ -29,11 +32,24 @@
|
|||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const projectsStore = useProjectsStore()
|
const projectsStore = useProjectsStore()
|
||||||
const project = ref<ProjectParams>({
|
|
||||||
|
const initialProject: ProjectParams = {
|
||||||
name: '',
|
name: '',
|
||||||
logo: '',
|
logo: '',
|
||||||
description: '',
|
description: '',
|
||||||
logo_as_bg: false
|
logo_as_bg: false
|
||||||
|
}
|
||||||
|
|
||||||
|
const project = ref<ProjectParams>({ ...initialProject })
|
||||||
|
const isFormValid = ref(false)
|
||||||
|
|
||||||
|
const isDirty = computed(() => {
|
||||||
|
return (
|
||||||
|
project.value.name !== initialProject.name ||
|
||||||
|
project.value.logo !== initialProject.logo ||
|
||||||
|
project.value.description !== initialProject.description ||
|
||||||
|
project.value.logo_as_bg !== initialProject.logo_as_bg
|
||||||
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
async function addProject (data: ProjectParams) {
|
async function addProject (data: ProjectParams) {
|
||||||
|
|||||||
@@ -5,11 +5,18 @@
|
|||||||
{{$t('forgot_password__password_recovery')}}
|
{{$t('forgot_password__password_recovery')}}
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<account-helper :type />
|
<pn-scroll-list>
|
||||||
|
<account-helper :type :email="email"/>
|
||||||
|
</pn-scroll-list>
|
||||||
</pn-page-card>
|
</pn-page-card>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { useRoute } from 'vue-router' // Добавляем импорт
|
||||||
import accountHelper from 'components/admin/accountHelper.vue'
|
import accountHelper from 'components/admin/accountHelper.vue'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
const type = 'forgot'
|
const type = 'forgot'
|
||||||
|
const email = ref(route.query.email as string)
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<q-page class="flex column items-center justify-between">
|
<q-page class="flex column items-center justify-between">
|
||||||
|
|
||||||
<q-card
|
<q-card
|
||||||
id="login_block"
|
id="login_block"
|
||||||
flat
|
flat
|
||||||
@@ -9,7 +8,7 @@
|
|||||||
<login-logo
|
<login-logo
|
||||||
class="col-grow q-pa-md"
|
class="col-grow q-pa-md"
|
||||||
:style="{ alignItems: 'flex-end' }"
|
:style="{ alignItems: 'flex-end' }"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div class = "q-ma-md flex column input-login">
|
<div class = "q-ma-md flex column input-login">
|
||||||
<q-input
|
<q-input
|
||||||
@@ -18,7 +17,6 @@
|
|||||||
filled
|
filled
|
||||||
class = "q-mb-md"
|
class = "q-mb-md"
|
||||||
:label = "$t('login__email')"
|
:label = "$t('login__email')"
|
||||||
:rules="['email']"
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<q-input
|
<q-input
|
||||||
@@ -71,6 +69,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
|
v-if="isTelegramApp"
|
||||||
id="alt_login"
|
id="alt_login"
|
||||||
class="w80 q-flex column items-center q-pt-xl"
|
class="w80 q-flex column items-center q-pt-xl"
|
||||||
>
|
>
|
||||||
@@ -85,11 +84,18 @@
|
|||||||
no-caps
|
no-caps
|
||||||
color="primary"
|
color="primary"
|
||||||
:disabled="!acceptTermsOfUse"
|
:disabled="!acceptTermsOfUse"
|
||||||
|
@click="handleTelegramLogin"
|
||||||
>
|
>
|
||||||
<span class="text-blue">
|
<div class="flex items-center text-blue">
|
||||||
<q-icon name="telegram" size="md" class="q-mx-none text-blue"/>
|
<q-icon name="telegram" size="md" class="q-mx-none text-blue"/>
|
||||||
Alex mart
|
<div class="q-ml-xs ellipsis" style="max-width: 100px">
|
||||||
</span>
|
{{
|
||||||
|
tgUser?.first_name +
|
||||||
|
(tgUser?.first_name && tgUser?.last_name ? ' ' : '') +
|
||||||
|
tgUser?.last_name
|
||||||
|
}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</q-btn>
|
</q-btn>
|
||||||
</div>
|
</div>
|
||||||
</q-card>
|
</q-card>
|
||||||
@@ -114,22 +120,36 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from 'vue'
|
import { ref, computed, inject } from 'vue'
|
||||||
|
import { useQuasar } from 'quasar'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import loginLogo from 'components/admin/login-page/loginLogo.vue'
|
import loginLogo from 'components/admin/login-page/loginLogo.vue'
|
||||||
// import { useI18n } from "vue-i18n"
|
import { useI18n } from "vue-i18n"
|
||||||
|
import { useAuthStore } from 'src/stores/auth'
|
||||||
|
import type { WebApp } from '@twa-dev/types'
|
||||||
|
|
||||||
|
const tg = inject('tg') as WebApp
|
||||||
|
const tgUser = tg.initDataUnsafe.user
|
||||||
|
|
||||||
|
const authStore = useAuthStore()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
// const { t } = useI18n()
|
const $q = useQuasar()
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
const login = ref<string>('')
|
const login = ref<string>('')
|
||||||
const password = ref<string>('')
|
const password = ref<string>('')
|
||||||
const isPwd = ref<boolean>(true)
|
const isPwd = ref<boolean>(true)
|
||||||
const acceptTermsOfUse = ref<boolean>(true)
|
const acceptTermsOfUse = ref<boolean>(true)
|
||||||
|
|
||||||
/* function rules () :Record<string, Array<(value: string) => boolean | string>> {
|
function onErrorLogin () {
|
||||||
return {
|
$q.notify({
|
||||||
email: [value => (value.length <= 25) || t('login__incorrect_email')]}
|
message: t('login__incorrect_login_data'),
|
||||||
} */
|
type: 'negative',
|
||||||
|
position: 'bottom',
|
||||||
|
timeout: 2000,
|
||||||
|
multiLine: true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
async function sendAuth() {
|
async function sendAuth() {
|
||||||
console.log('1')
|
console.log('1')
|
||||||
@@ -137,13 +157,31 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function forgotPwd() {
|
async function forgotPwd() {
|
||||||
await router.push({ name: 'forgot_password' })
|
await router.push({
|
||||||
|
name: 'recovery_password',
|
||||||
|
query: { email: login.value }
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
async function createAccount() {
|
async function createAccount() {
|
||||||
await router.push({ name: 'create_account' })
|
await router.push({ name: 'create_account' })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isTelegramApp = computed(() => {
|
||||||
|
// @ts-expect-ignore
|
||||||
|
return !!window.Telegram?.WebApp?.initData
|
||||||
|
})
|
||||||
|
|
||||||
|
/* const handleSubmit = async () => {
|
||||||
|
await authStore.loginWithCredentials(email.value, password.value)
|
||||||
|
} */
|
||||||
|
|
||||||
|
async function handleTelegramLogin () {
|
||||||
|
// @ts-expect-ignore
|
||||||
|
const initData = window.Telegram.WebApp.initData
|
||||||
|
await authStore.loginWithTelegram(initData)
|
||||||
|
}
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
@@ -14,70 +14,71 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<pn-scroll-list>
|
<pn-scroll-list>
|
||||||
<div class="flex column">
|
<div class="flex column items-center q-ma-lg">
|
||||||
<div class="flex column items-center col-grow q-pa-lg">
|
|
||||||
<q-avatar size="100px">
|
<q-avatar size="100px">
|
||||||
<q-img :src="person.logo"/>
|
<q-img :src="person.logo"/>
|
||||||
</q-avatar>
|
</q-avatar>
|
||||||
<div class="flex row items-start justify-center no-wrap">
|
|
||||||
|
|
||||||
|
<div class="flex row items-start justify-center no-wrap q-pb-lg">
|
||||||
<div class="flex column justify-center">
|
<div class="flex column justify-center">
|
||||||
<div class="text-bold q-pr-xs text-center">{{ person.tname }}</div>
|
<div class="text-bold q-pr-xs text-center">{{ person.tname }}</div>
|
||||||
<div caption class="text-blue text-caption">{{ person.tusername }}</div>
|
<div caption class="text-blue text-caption">{{ person.tusername }}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<q-input
|
<div class="q-gutter-y-lg w100">
|
||||||
v-model="person.name"
|
<q-input
|
||||||
dense
|
v-model="person.name"
|
||||||
filled
|
dense
|
||||||
class = "q-my-sm w100"
|
filled
|
||||||
:label = "$t('person_card__name')"
|
class = "w100"
|
||||||
/>
|
:label = "$t('person_card__name')"
|
||||||
|
/>
|
||||||
|
|
||||||
<q-select
|
<q-select
|
||||||
v-if="companies"
|
v-if="companies"
|
||||||
v-model="person.company"
|
v-model="person.company"
|
||||||
:options="companies"
|
:options="companies"
|
||||||
dense
|
dense
|
||||||
filled
|
filled
|
||||||
class="q-my-sm w100"
|
class="w100"
|
||||||
:label = "$t('person_card__company')"
|
:label = "$t('person_card__company')"
|
||||||
>
|
>
|
||||||
<template #option="scope">
|
<template #option="scope">
|
||||||
<q-item v-bind="scope.itemProps">
|
<q-item v-bind="scope.itemProps">
|
||||||
<q-item-section avatar>
|
<q-item-section avatar>
|
||||||
<q-avatar rounded size="md">
|
<q-avatar rounded size="md">
|
||||||
<img v-if="scope.opt.logo" :src="scope.opt.logo"/>
|
<img v-if="scope.opt.logo" :src="scope.opt.logo"/>
|
||||||
<pn-auto-avatar v-else :name="scope.opt.name"/>
|
<pn-auto-avatar v-else :name="scope.opt.name"/>
|
||||||
</q-avatar>
|
</q-avatar>
|
||||||
</q-item-section>
|
</q-item-section>
|
||||||
<q-item-section>
|
<q-item-section>
|
||||||
<q-item-label>{{ scope.opt.name }}</q-item-label>
|
<q-item-label>{{ scope.opt.name }}</q-item-label>
|
||||||
</q-item-section>
|
</q-item-section>
|
||||||
</q-item>
|
</q-item>
|
||||||
</template>
|
</template>
|
||||||
<template v-slot:selected>
|
<template #selected>
|
||||||
{{ JSON.parse(JSON.stringify(person.company)).name }}
|
{{ JSON.parse(JSON.stringify(person.company)).name }}
|
||||||
</template>
|
</template>
|
||||||
</q-select>
|
</q-select>
|
||||||
|
|
||||||
<q-input
|
<q-input
|
||||||
v-model="person.department"
|
v-model="person.department"
|
||||||
dense
|
dense
|
||||||
filled
|
filled
|
||||||
class = "q-my-sm w100"
|
class = "w100"
|
||||||
:label = "$t('person_card__department')"
|
:label = "$t('person_card__department')"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<q-input
|
<q-input
|
||||||
v-model="person.role"
|
v-model="person.role"
|
||||||
dense
|
dense
|
||||||
filled
|
filled
|
||||||
class = "q-my-sm w100"
|
class = "w100"
|
||||||
:label = "$t('person_card__role')"
|
:label = "$t('person_card__role')"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</pn-scroll-list>
|
</pn-scroll-list>
|
||||||
</pn-page-card>
|
</pn-page-card>
|
||||||
@@ -108,5 +109,5 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss">
|
<style>
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
<template>
|
<template>
|
||||||
>>{{ project }}
|
|
||||||
<pn-page-card>
|
<pn-page-card>
|
||||||
<template #title>
|
<template #title>
|
||||||
<div class="flex items-center justify-between col-grow">
|
<div class="flex items-center justify-between col-grow">
|
||||||
@@ -7,6 +6,7 @@
|
|||||||
<span>{{ $t('project_card__project_card') }}</span>
|
<span>{{ $t('project_card__project_card') }}</span>
|
||||||
</div>
|
</div>
|
||||||
<q-btn
|
<q-btn
|
||||||
|
v-if="isFormValid && isDirty()"
|
||||||
@click="updateProject()"
|
@click="updateProject()"
|
||||||
flat
|
flat
|
||||||
round
|
round
|
||||||
@@ -16,7 +16,11 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<pn-scroll-list>
|
<pn-scroll-list>
|
||||||
<project-info-block v-if="project" v-model="project"/>
|
<project-info-block
|
||||||
|
v-if="project"
|
||||||
|
v-model="project"
|
||||||
|
@valid="isFormValid = $event"
|
||||||
|
/>
|
||||||
</pn-scroll-list>
|
</pn-scroll-list>
|
||||||
</pn-page-card>
|
</pn-page-card>
|
||||||
</template>
|
</template>
|
||||||
@@ -27,9 +31,7 @@ import { useRouter, useRoute } from 'vue-router'
|
|||||||
import { useProjectsStore } from 'stores/projects'
|
import { useProjectsStore } from 'stores/projects'
|
||||||
import projectInfoBlock from 'components/admin/projectInfoBlock.vue'
|
import projectInfoBlock from 'components/admin/projectInfoBlock.vue'
|
||||||
import type { Project } from '../types'
|
import type { Project } from '../types'
|
||||||
import { parseIntString } from 'boot/helpers'
|
import { parseIntString, isObjEqual } from 'boot/helpers'
|
||||||
|
|
||||||
// import { isObjEqual } from '../boot/helpers'
|
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
@@ -38,12 +40,23 @@ const projectsStore = useProjectsStore()
|
|||||||
const project = ref<Project>()
|
const project = ref<Project>()
|
||||||
const id = parseIntString(route.params.id)
|
const id = parseIntString(route.params.id)
|
||||||
|
|
||||||
|
const isFormValid = ref(false)
|
||||||
|
|
||||||
|
const originalProject = ref<Project>({} as Project)
|
||||||
|
|
||||||
|
const isDirty = () => {
|
||||||
|
return project.value && !isObjEqual(originalProject.value, project.value)
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
if (id && projectsStore.projectById(id)) {
|
if (id && projectsStore.projectById(id)) {
|
||||||
project.value = projectsStore.projectById(id)
|
const initial = projectsStore.projectById(id)
|
||||||
} else {
|
|
||||||
await abort()
|
project.value = { ...initial } as Project
|
||||||
}
|
originalProject.value = JSON.parse(JSON.stringify(project.value))
|
||||||
|
} else {
|
||||||
|
await abort()
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
function updateProject () {
|
function updateProject () {
|
||||||
|
|||||||
@@ -27,7 +27,6 @@
|
|||||||
v-model="tabSelect"
|
v-model="tabSelect"
|
||||||
dense
|
dense
|
||||||
align="justify"
|
align="justify"
|
||||||
switch-indicator
|
|
||||||
>
|
>
|
||||||
<q-route-tab
|
<q-route-tab
|
||||||
v-for="tab in tabs"
|
v-for="tab in tabs"
|
||||||
@@ -50,7 +49,7 @@
|
|||||||
{{ currentProject?.[tab.name as keyof typeof currentProject] ?? 0 }}
|
{{ currentProject?.[tab.name as keyof typeof currentProject] ?? 0 }}
|
||||||
</q-badge>
|
</q-badge>
|
||||||
</q-icon>
|
</q-icon>
|
||||||
<span>{{$t(tab.label)}}</span>
|
<span class="text-caption">{{$t(tab.label)}}</span>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</q-route-tab>
|
</q-route-tab>
|
||||||
@@ -78,7 +77,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
const tabs = [
|
const tabs = [
|
||||||
{name: 'chats', label: 'project__chats', icon: 'mdi-message-outline', component: tabComponents.projectPageChats, to: { name: 'chats'} },
|
{name: 'chats', label: 'project__chats', icon: 'mdi-chat-outline', component: tabComponents.projectPageChats, to: { name: 'chats'} },
|
||||||
{name: 'persons', label: 'project__persons', icon: 'mdi-account-outline', component: tabComponents.projectPagePersons, to: { name: 'persons'} },
|
{name: 'persons', label: 'project__persons', icon: 'mdi-account-outline', component: tabComponents.projectPagePersons, to: { name: 'persons'} },
|
||||||
{name: 'companies', label: 'project__companies', icon: 'mdi-account-group-outline', component: tabComponents.projectPageCompanies, to: { name: 'companies'} },
|
{name: 'companies', label: 'project__companies', icon: 'mdi-account-group-outline', component: tabComponents.projectPageCompanies, to: { name: 'companies'} },
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -15,11 +15,15 @@
|
|||||||
dense
|
dense
|
||||||
>
|
>
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<q-avatar size="32px">
|
<q-avatar v-if="tgUser?.photo_url" size="32px">
|
||||||
<img src="https://cdn.quasar.dev/img/avatar2.jpg">
|
<q-img :src="tgUser.photo_url"/>
|
||||||
</q-avatar>
|
</q-avatar>
|
||||||
<div class="q-ml-xs ellipsis" style="max-width: 100px">
|
<div class="q-ml-xs ellipsis" style="max-width: 100px">
|
||||||
Alex mart
|
{{
|
||||||
|
tgUser?.first_name +
|
||||||
|
(tgUser?.first_name && tgUser?.last_name ? ' ' : '') +
|
||||||
|
tgUser?.last_name
|
||||||
|
}}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</q-btn>
|
</q-btn>
|
||||||
@@ -62,28 +66,25 @@
|
|||||||
<q-item-section>
|
<q-item-section>
|
||||||
<q-item-label lines="1" class="text-bold">{{ item.name }}</q-item-label>
|
<q-item-label lines="1" class="text-bold">{{ item.name }}</q-item-label>
|
||||||
<q-item-label caption lines="2">{{item.description}}</q-item-label>
|
<q-item-label caption lines="2">{{item.description}}</q-item-label>
|
||||||
<q-item-label caption lines="1">
|
|
||||||
<div class = "flex justify-start items-center">
|
</q-item-section>
|
||||||
<div class="q-mr-sm">
|
<q-item-section side class="text-caption ">
|
||||||
<q-icon name="mdi-message-outline" class="q-mr-sm"/>
|
<div class="flex items-center column">
|
||||||
<span>{{ item.chats }} </span>
|
<div class="flex items-center">
|
||||||
</div>
|
<q-icon name="mdi-chat-outline"/>
|
||||||
<div class="q-mr-sm">
|
<span>{{ item.chats }} </span>
|
||||||
<q-icon name="mdi-account-outline" class="q-mx-sm"/>
|
</div>
|
||||||
<span>{{ item.persons }}</span>
|
<div class="flex items-center">
|
||||||
</div>
|
<q-icon name="mdi-account-outline"/>
|
||||||
<div class="q-mx-sm">
|
<span>{{ item.persons }}</span>
|
||||||
<q-icon name="mdi-account-group-outline" class="q-mr-sm"/>
|
</div>
|
||||||
<span>{{ item.companies }} </span>
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</q-item-label>
|
|
||||||
</q-item-section>
|
</q-item-section>
|
||||||
</q-item>
|
</q-item>
|
||||||
</q-list>
|
</q-list>
|
||||||
<div v-if="archiveProjects.length !== 0" class="flex column items-center w100" :class="showArchive ? 'bg-grey-12' : ''">
|
<div v-if="archiveProjects.length !== 0" class="flex column items-center w100" :class="showArchive ? 'bg-grey-12' : ''">
|
||||||
<div id="btn_show_archive">
|
<div id="btn_show_archive">
|
||||||
<q-btn-dropdown color="grey" flat no-caps @click="showArchive = !showArchive" dropdown-icon="arrow_drop_down">
|
<q-btn-dropdown color="grey" flat no-caps @click="showArchive = !showArchive" dropdown-icon="arrow_drop_up">
|
||||||
<template #label>
|
<template #label>
|
||||||
<span class="text-caption">
|
<span class="text-caption">
|
||||||
<span v-if="!showArchive">{{ $t('projects__show_archive') }}</span>
|
<span v-if="!showArchive">{{ $t('projects__show_archive') }}</span>
|
||||||
@@ -159,9 +160,13 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, watch } from 'vue'
|
import { ref, computed, watch, inject } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { useProjectsStore } from 'stores/projects'
|
import { useProjectsStore } from 'stores/projects'
|
||||||
|
import type { WebApp } from '@twa-dev/types'
|
||||||
|
|
||||||
|
const tg = inject('tg') as WebApp
|
||||||
|
const tgUser = tg.initDataUnsafe.user
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const projectsStore = useProjectsStore()
|
const projectsStore = useProjectsStore()
|
||||||
|
|||||||
93
src/pages/SettingsPage.vue
Normal file
93
src/pages/SettingsPage.vue
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
<template>
|
||||||
|
<pn-page-card>
|
||||||
|
<template #title>
|
||||||
|
<div class="flex items-center justify-between col-grow">
|
||||||
|
<div>
|
||||||
|
{{ $t('settings__title') }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<pn-scroll-list>
|
||||||
|
<q-list separator>
|
||||||
|
<q-item>
|
||||||
|
<q-item-section avatar>
|
||||||
|
<q-avatar color="primary" rounded text-color="white" icon="mdi-translate" size="md" />
|
||||||
|
</q-item-section>
|
||||||
|
<q-item-section>
|
||||||
|
<span>{{ $t('settings__language') }}</span>
|
||||||
|
</q-item-section>
|
||||||
|
<q-item-section>
|
||||||
|
<q-select
|
||||||
|
class="fix-input-right text-body1"
|
||||||
|
v-model="locale"
|
||||||
|
:options="localeOptions"
|
||||||
|
dense
|
||||||
|
borderless
|
||||||
|
emit-value
|
||||||
|
map-options
|
||||||
|
hide-bottom-space
|
||||||
|
/>
|
||||||
|
</q-item-section>
|
||||||
|
</q-item>
|
||||||
|
<q-item>
|
||||||
|
<q-item-section avatar>
|
||||||
|
<q-avatar color="primary" rounded text-color="white" icon="mdi-format-size" size="md" />
|
||||||
|
</q-item-section>
|
||||||
|
<q-item-section>
|
||||||
|
<span>{{ $t('settings__font_size') }}</span>
|
||||||
|
</q-item-section>
|
||||||
|
<q-item-section>
|
||||||
|
<div class="flex justify-end">
|
||||||
|
<q-btn
|
||||||
|
@click="textSizeStore.decreaseFontSize()"
|
||||||
|
color="negative" flat
|
||||||
|
icon="mdi-format-font-size-decrease"
|
||||||
|
class="q-pa-sm q-mx-xs"
|
||||||
|
:disable="currentTextSize <= minTextSize"
|
||||||
|
/>
|
||||||
|
<q-btn
|
||||||
|
@click="textSizeStore.increaseFontSize()"
|
||||||
|
color="positive" flat
|
||||||
|
icon="mdi-format-font-size-increase"
|
||||||
|
class="q-pa-sm q-mx-xs"
|
||||||
|
:disable="currentTextSize >= maxTextSize"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</q-item-section>
|
||||||
|
</q-item>
|
||||||
|
</q-list>
|
||||||
|
</pn-scroll-list>
|
||||||
|
</pn-page-card>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import { watch, ref } from 'vue'
|
||||||
|
import { useTextSizeStore } from 'src/stores/textSize'
|
||||||
|
|
||||||
|
const { locale } = useI18n()
|
||||||
|
|
||||||
|
const savedLocale = localStorage.getItem('locale') || 'en-US'
|
||||||
|
locale.value = savedLocale
|
||||||
|
|
||||||
|
const localeOptions = ref([
|
||||||
|
{ value: 'en-US', label: 'English' },
|
||||||
|
{ value: 'ru-RU', label: 'Русский' }
|
||||||
|
])
|
||||||
|
|
||||||
|
watch(locale, (newLocale) => {
|
||||||
|
localStorage.setItem('locale', newLocale)
|
||||||
|
})
|
||||||
|
|
||||||
|
const textSizeStore = useTextSizeStore()
|
||||||
|
const currentTextSize = textSizeStore.currentFontSize
|
||||||
|
const maxTextSize = textSizeStore.maxFontSize
|
||||||
|
const minTextSize = textSizeStore.minFontSize
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.fix-input-right :deep(.q-field__native) {
|
||||||
|
justify-content: end;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
22
src/pages/TermsPage.vue
Normal file
22
src/pages/TermsPage.vue
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
<template>
|
||||||
|
<pn-page-card>
|
||||||
|
<template #title>
|
||||||
|
<div class="flex items-center justify-between col-grow">
|
||||||
|
<div>
|
||||||
|
{{ $t('terms__title') }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<pn-scroll-list>
|
||||||
|
Фигня которую никто не читает!
|
||||||
|
</pn-scroll-list>
|
||||||
|
</pn-page-card>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
</style>
|
||||||
@@ -6,7 +6,8 @@ import {
|
|||||||
createWebHistory,
|
createWebHistory,
|
||||||
} from 'vue-router'
|
} from 'vue-router'
|
||||||
import routes from './routes'
|
import routes from './routes'
|
||||||
import { useProjectsStore } from '../stores/projects'
|
import { useAuthStore } from 'stores/auth'
|
||||||
|
import { useProjectsStore } from 'stores/projects'
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* If not building with SSR mode, you can
|
* If not building with SSR mode, you can
|
||||||
@@ -32,7 +33,61 @@ export default defineRouter(function (/* { store, ssrContext } */) {
|
|||||||
history: createHistory(process.env.VUE_ROUTER_BASE),
|
history: createHistory(process.env.VUE_ROUTER_BASE),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const publicPaths = ['/login', '/terms-of-use', '/create-account', '/recovery-password']
|
||||||
|
|
||||||
|
Router.beforeEach(async (to) => {
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
|
||||||
|
// Инициализация хранилища перед проверкой
|
||||||
|
if (!authStore.isInitialized) {
|
||||||
|
await authStore.initialize()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверка авторизации для непубличных маршрутов
|
||||||
|
if (!publicPaths.includes(to.path)) {
|
||||||
|
if (!authStore.isAuthenticated) {
|
||||||
|
return {
|
||||||
|
path: '/login',
|
||||||
|
query: { redirect: to.fullPath }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Редирект авторизованных пользователей с публичных маршрутов
|
||||||
|
if (publicPaths.includes(to.path) && authStore.isAuthenticated) {
|
||||||
|
return { path: '/' }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleBackButton = async () => {
|
||||||
|
const currentRoute = Router.currentRoute.value
|
||||||
|
if (currentRoute.meta.backRoute) {
|
||||||
|
await Router.push(currentRoute.meta.backRoute);
|
||||||
|
} else {
|
||||||
|
if (window.history.length > 1) {
|
||||||
|
Router.go(-1)
|
||||||
|
} else {
|
||||||
|
await Router.push('/projects')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
Router.afterEach((to) => {
|
Router.afterEach((to) => {
|
||||||
|
const BackButton = window.Telegram?.WebApp?.BackButton;
|
||||||
|
if (BackButton) {
|
||||||
|
// Управление видимостью
|
||||||
|
if (to.meta.hideBackButton) {
|
||||||
|
BackButton.hide()
|
||||||
|
} else {
|
||||||
|
BackButton.show()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Обновляем обработчик клика
|
||||||
|
BackButton.offClick(handleBackButton as () => void)
|
||||||
|
BackButton.onClick(handleBackButton as () => void)
|
||||||
|
}
|
||||||
|
|
||||||
if (!to.params.id) {
|
if (!to.params.id) {
|
||||||
const projectsStore = useProjectsStore()
|
const projectsStore = useProjectsStore()
|
||||||
projectsStore.setCurrentProjectId(null)
|
projectsStore.setCurrentProjectId(null)
|
||||||
|
|||||||
@@ -21,7 +21,8 @@ const routes: RouteRecordRaw[] = [
|
|||||||
{
|
{
|
||||||
name: 'projects',
|
name: 'projects',
|
||||||
path: '/projects',
|
path: '/projects',
|
||||||
component: () => import('pages/ProjectsPage.vue')
|
component: () => import('pages/ProjectsPage.vue'),
|
||||||
|
meta: { hideBackButton: true }
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'project_add',
|
name: 'project_add',
|
||||||
@@ -55,48 +56,81 @@ const routes: RouteRecordRaw[] = [
|
|||||||
{
|
{
|
||||||
name: 'chats',
|
name: 'chats',
|
||||||
path: 'chats',
|
path: 'chats',
|
||||||
component: () => import('../components/admin/project-page/ProjectPageChats.vue')
|
component: () => import('components/admin/project-page/ProjectPageChats.vue'),
|
||||||
|
meta: { backRoute: '/projects' }
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'persons',
|
name: 'persons',
|
||||||
path: 'persons',
|
path: 'persons',
|
||||||
component: () => import('../components/admin/project-page/ProjectPagePersons.vue')
|
component: () => import('components/admin/project-page/ProjectPagePersons.vue'),
|
||||||
|
meta: { backRoute: '/projects' }
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'companies',
|
name: 'companies',
|
||||||
path: 'companies',
|
path: 'companies',
|
||||||
component: () => import('../components/admin/project-page/ProjectPageCompanies.vue')
|
component: () => import('components/admin/project-page/ProjectPageCompanies.vue'),
|
||||||
|
meta: { backRoute: '/projects' }
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/account',
|
name: 'company_info',
|
||||||
|
path: '/project/:id(\\d+)/company/:companyId',
|
||||||
|
component: () => import('pages/CompanyInfoPage.vue'),
|
||||||
|
beforeEnter: setProjectBeforeEnter
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'person_info',
|
||||||
|
path: '/project/:id(\\d+)/person/:personId',
|
||||||
|
component: () => import('pages/PersonInfoPage.vue'),
|
||||||
|
beforeEnter: setProjectBeforeEnter
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
name: 'account',
|
name: 'account',
|
||||||
|
path: '/account',
|
||||||
component: () => import('pages/AccountPage.vue')
|
component: () => import('pages/AccountPage.vue')
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'create_account',
|
||||||
|
path: '/create-account',
|
||||||
|
component: () => import('pages/CreateAccountPage.vue')
|
||||||
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
path: '/login',
|
|
||||||
name: 'login',
|
name: 'login',
|
||||||
|
path: '/login',
|
||||||
component: () => import('pages/LoginPage.vue')
|
component: () => import('pages/LoginPage.vue')
|
||||||
},
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
path: '/recovery-password',
|
|
||||||
name: 'recovery_password',
|
name: 'recovery_password',
|
||||||
|
path: '/recovery-password',
|
||||||
component: () => import('pages/ForgotPasswordPage.vue')
|
component: () => import('pages/ForgotPasswordPage.vue')
|
||||||
},
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
path: '/create-company',
|
name: 'add_company',
|
||||||
name: 'create_company',
|
path: '/add-company',
|
||||||
component: () => import('pages/CreateCompanyPage.vue')
|
component: () => import('pages/CreateCompanyPage.vue')
|
||||||
},
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
path: '/person-info',
|
|
||||||
name: 'person_info',
|
name: 'person_info',
|
||||||
|
path: '/person-info',
|
||||||
component: () => import('pages/PersonInfoPage.vue')
|
component: () => import('pages/PersonInfoPage.vue')
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
name: 'settings',
|
||||||
|
path: '/settings',
|
||||||
|
component: () => import('pages/SettingsPage.vue')
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
name: 'terms',
|
||||||
|
path: '/terms-of-use',
|
||||||
|
component: () => import('pages/TermsPage.vue')
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|||||||
65
src/stores/auth.ts
Normal file
65
src/stores/auth.ts
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { ref, computed } from 'vue'
|
||||||
|
import { api } from 'boot/axios'
|
||||||
|
|
||||||
|
interface User {
|
||||||
|
id: string
|
||||||
|
email?: string
|
||||||
|
username: string
|
||||||
|
first_name?: string
|
||||||
|
last_name?: string
|
||||||
|
avatar?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useAuthStore = defineStore('auth', () => {
|
||||||
|
// State
|
||||||
|
const user = ref<User | null>(null)
|
||||||
|
const isInitialized = ref(false)
|
||||||
|
|
||||||
|
// Getters
|
||||||
|
const isAuthenticated = computed(() => !!user.value)
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
const initialize = async () => {
|
||||||
|
try {
|
||||||
|
const { data } = await api.get('/customer/profile')
|
||||||
|
user.value = data
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error)
|
||||||
|
user.value = null
|
||||||
|
} finally {
|
||||||
|
isInitialized.value = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const loginWithCredentials = async (email: string, password: string) => {
|
||||||
|
// будет переделано на беке - нужно сменить урл
|
||||||
|
await api.post('/api/admin/customer/login', { email, password }, { withCredentials: true })
|
||||||
|
await initialize()
|
||||||
|
}
|
||||||
|
|
||||||
|
const loginWithTelegram = async (initData: string) => {
|
||||||
|
await api.post('/api/admin/customer/login', { initData }, { withCredentials: true })
|
||||||
|
await initialize()
|
||||||
|
}
|
||||||
|
|
||||||
|
const logout = async () => {
|
||||||
|
try {
|
||||||
|
await api.get('/customer/logout', {})
|
||||||
|
} finally {
|
||||||
|
user.value = null
|
||||||
|
// @ts-expect-ignore
|
||||||
|
// window.Telegram?.WebApp.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
user,
|
||||||
|
isAuthenticated,
|
||||||
|
isInitialized,
|
||||||
|
initialize,
|
||||||
|
loginWithCredentials,
|
||||||
|
loginWithTelegram,
|
||||||
|
logout
|
||||||
|
}
|
||||||
|
})
|
||||||
121
src/stores/textSize.ts
Normal file
121
src/stores/textSize.ts
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
import { api } from 'boot/axios'
|
||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { ref, computed } from 'vue'
|
||||||
|
|
||||||
|
interface FontSizeResponse {
|
||||||
|
fontSize: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FontSizeError {
|
||||||
|
message: string
|
||||||
|
code: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useTextSizeStore = defineStore('textSize', () => {
|
||||||
|
// State
|
||||||
|
const baseSize = ref<number>(16) // Значение по умолчанию
|
||||||
|
const isLoading = ref<boolean>(false)
|
||||||
|
const error = ref<FontSizeError | null>(null)
|
||||||
|
const isInitialized = ref<boolean>(false)
|
||||||
|
|
||||||
|
// Константы
|
||||||
|
const minFontSize = 12
|
||||||
|
const maxFontSize = 20
|
||||||
|
const fontSizeStep = 2
|
||||||
|
|
||||||
|
// Getters
|
||||||
|
const currentFontSize = computed(() => baseSize.value)
|
||||||
|
const canIncrease = computed(() => baseSize.value < maxFontSize)
|
||||||
|
const canDecrease = computed(() => baseSize.value > minFontSize)
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
const fetchFontSize = async () => {
|
||||||
|
try {
|
||||||
|
isLoading.value = true
|
||||||
|
const response = await api.get<FontSizeResponse>('customer/settings')
|
||||||
|
baseSize.value = clampFontSize(response.data.fontSize)
|
||||||
|
updateCssVariable()
|
||||||
|
} catch (err) {
|
||||||
|
handleError(err, 'Failed to fetch font size')
|
||||||
|
baseSize.value = 16 // Fallback к значению по умолчанию
|
||||||
|
throw err
|
||||||
|
} finally {
|
||||||
|
isLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateFontSize = async (newSize: number) => {
|
||||||
|
try {
|
||||||
|
const validatedSize = clampFontSize(newSize)
|
||||||
|
|
||||||
|
await api.put('customer/settings', { fontSize: validatedSize })
|
||||||
|
|
||||||
|
baseSize.value = validatedSize
|
||||||
|
updateCssVariable()
|
||||||
|
error.value = null
|
||||||
|
} catch (err) {
|
||||||
|
handleError(err, 'Failed to update font size')
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const increaseFontSize = async () => {
|
||||||
|
if (!canIncrease.value) return
|
||||||
|
await updateFontSize(baseSize.value + fontSizeStep)
|
||||||
|
}
|
||||||
|
|
||||||
|
const decreaseFontSize = async () => {
|
||||||
|
if (!canDecrease.value) return
|
||||||
|
await updateFontSize(baseSize.value - fontSizeStep)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helpers
|
||||||
|
const clampFontSize = (size: number): number => {
|
||||||
|
return Math.max(minFontSize, Math.min(size, maxFontSize))
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateCssVariable = () => {
|
||||||
|
document.documentElement.style.setProperty(
|
||||||
|
'--dynamic-font-size',
|
||||||
|
`${baseSize.value}px`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleError = (err: unknown, defaultMessage: string) => {
|
||||||
|
const apiError = err as { response?: { data: { message: string; code: number } } }
|
||||||
|
error.value = {
|
||||||
|
message: apiError?.response?.data?.message || defaultMessage,
|
||||||
|
code: apiError?.response?.data?.code || 500
|
||||||
|
}
|
||||||
|
console.error('FontSize Error:', error.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Инициализация при первом использовании
|
||||||
|
const initialize = async () => {
|
||||||
|
if (isInitialized.value) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
await fetchFontSize()
|
||||||
|
} catch {
|
||||||
|
// Оставляем значение по умолчанию
|
||||||
|
} finally {
|
||||||
|
isInitialized.value = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
baseSize,
|
||||||
|
currentFontSize,
|
||||||
|
minFontSize,
|
||||||
|
maxFontSize,
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
canIncrease,
|
||||||
|
canDecrease,
|
||||||
|
fetchFontSize,
|
||||||
|
increaseFontSize,
|
||||||
|
decreaseFontSize,
|
||||||
|
updateFontSize,
|
||||||
|
initialize
|
||||||
|
}
|
||||||
|
})
|
||||||
10
src/types.ts
10
src/types.ts
@@ -1,3 +1,13 @@
|
|||||||
|
import type { WebApp } from "@twa-dev/types"
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
Telegram: {
|
||||||
|
WebApp: WebApp
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
interface ProjectParams {
|
interface ProjectParams {
|
||||||
name: string
|
name: string
|
||||||
description?: string
|
description?: string
|
||||||
|
|||||||
27
todo.txt
27
todo.txt
@@ -2,10 +2,10 @@
|
|||||||
|
|
||||||
1. Login:
|
1. Login:
|
||||||
+ Окно "Забыли пароль?"
|
+ Окно "Забыли пароль?"
|
||||||
- Надпись "Неправильный логин или пароль"
|
+ Надпись "Неправильный логин или пароль"
|
||||||
- Окно "Регистрация нового пользователя"
|
+ Окно "Регистрация нового пользователя"
|
||||||
- Переводы
|
- Переводы
|
||||||
- Верификация e-mail
|
+ Верификация e-mail (не делать - плохо выглядит)
|
||||||
|
|
||||||
2. Account:
|
2. Account:
|
||||||
+ Работа с изображением логотипа компании
|
+ Работа с изображением логотипа компании
|
||||||
@@ -17,14 +17,14 @@
|
|||||||
+ (баг) Промотка шапки в конце прокрутки списка проектов
|
+ (баг) Промотка шапки в конце прокрутки списка проектов
|
||||||
+ Добавить тень при прокрутке списка на заголовке "Проекты"
|
+ Добавить тень при прокрутке списка на заголовке "Проекты"
|
||||||
+ Окно добавить проект
|
+ Окно добавить проект
|
||||||
- При добавлении проекта проверять валидность, если не валидно то скрывать галку "Применить"
|
+ При добавлении проекта проверять валидность, если не валидно то скрывать галку "Применить"
|
||||||
|
|
||||||
4.1 ProjectPage - Заголовок:
|
4.1 ProjectPage - Заголовок:
|
||||||
- Анимация расширенной версии (плавное увеличение блока div)
|
+ Анимация расширенной версии (плавное увеличение блока div)
|
||||||
+ Окно редактирования проекта
|
+ Окно редактирования проекта
|
||||||
- При изменении свойств проекта проверять валидность, если не валидно то скрывать галку "Применить"
|
+ При изменении свойств проекта проверять валидность, если не валидно то скрывать галку "Применить"
|
||||||
- Продумать backup
|
+ Продумать backup (потом)
|
||||||
- Окно отправки проекта в архив
|
+ Окно отправки проекта в архив
|
||||||
+ Окно удаления проекта
|
+ Окно удаления проекта
|
||||||
|
|
||||||
4.2 ProjectPage - Чаты:
|
4.2 ProjectPage - Чаты:
|
||||||
@@ -48,9 +48,18 @@
|
|||||||
- При изменении компании проверять валидность, если не валидно то скрывать галку "Применить"
|
- При изменении компании проверять валидность, если не валидно то скрывать галку "Применить"
|
||||||
- Окно настройки видимости компаний
|
- Окно настройки видимости компаний
|
||||||
|
|
||||||
4.5 ProjectPage - МаскировкаЖ
|
4.5 ProjectPage - Маскировка:
|
||||||
- Сделать стор и настроить компоненты
|
- Сделать стор и настроить компоненты
|
||||||
|
|
||||||
|
5. Settings:
|
||||||
|
- Роутинг
|
||||||
|
- Переключатель языков
|
||||||
|
- Встроить в Телеграмм
|
||||||
|
|
||||||
|
6. Лицензионное соглашение:
|
||||||
|
- Роутинг и заготовка
|
||||||
|
- Текст соглашения
|
||||||
|
- Встроить в Телеграмм
|
||||||
|
|
||||||
BUGS:
|
BUGS:
|
||||||
- 1. Прыгает кнопка fab при перещелкивании табов
|
- 1. Прыгает кнопка fab при перещелкивании табов
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
"boot/*": ["./src/boot/*"],
|
"boot/*": ["./src/boot/*"],
|
||||||
"stores/*": ["./src/stores/*"]
|
"stores/*": ["./src/stores/*"]
|
||||||
},
|
},
|
||||||
"types": ["node"]
|
"types": ["@twa-dev/types", "node"]
|
||||||
}
|
},
|
||||||
|
"include": ["src/**/*", "types/**/*"]
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user