This commit is contained in:
2025-04-14 10:27:58 +03:00
parent f977d6b3d4
commit 7e798a7a83
55 changed files with 5625 additions and 353 deletions

96
backend/app.js Normal file
View 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#039;')
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
View 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
View 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
View 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

Binary file not shown.

BIN
backend/data/db.sqlite-shm Normal file

Binary file not shown.

BIN
backend/data/db.sqlite-wal Normal file

Binary file not shown.

166
backend/data/init.sql Normal file
View 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
View 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

File diff suppressed because it is too large Load Diff

25
backend/package.json Normal file
View 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
View 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>

View 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>

Binary file not shown.

View File

@@ -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
View File

@@ -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",

View File

@@ -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",

View File

@@ -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

View File

@@ -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
View File

@@ -0,0 +1,6 @@
import { useAuthStore } from 'stores/auth'
export default async () => {
const authStore = useAuthStore()
await authStore.initialize()
}

View File

@@ -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
}); });

View File

@@ -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
View 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)
}
}

View File

@@ -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>('')

View File

@@ -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

View File

@@ -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 {

View File

@@ -64,18 +64,18 @@
</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
v-if="showFab"
icon="add" icon="add"
color="brand" color="brand"
direction="up" direction="up"
vertical-actions-align="right" vertical-actions-align="right"
@click="showOverlay = !showOverlay;" @click="showOverlay = !showOverlay"
> >
<q-fab-action <q-fab-action
v-for="item in fabMenu" v-for="item in fabMenu"
@@ -103,10 +103,8 @@
</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>

View File

@@ -54,7 +54,6 @@
</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]"
@@ -64,6 +63,7 @@
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) {
@@ -138,39 +141,37 @@
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 currentSlideEvent.value = null
} }
} }
function onConfirm() { function onConfirm() {
closedByUserAction.value = true closedByUserAction.value = true
if (deleteCompanyId.value) { if (deleteCompanyId.value) {
companiesStore.deleteCompany(deleteCompanyId.value) companiesStore.deleteCompany(deleteCompanyId.value)
} }
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>
.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)

View File

@@ -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>

View File

@@ -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>

View File

@@ -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 {

View File

@@ -26,3 +26,4 @@ $warning : #F2C037;
$lightgrey : #DCDCDC; $lightgrey : #DCDCDC;
$body-font-size: var(--dynamic-font-size)

View File

@@ -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

View File

@@ -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
})

View File

@@ -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>

View File

@@ -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
tag="div"
class="flex w100 company-container"
enter-active-class="animate__animated animate__fadeIn"
leave-active-class="animate__animated animate__fadeOut"
appear
>
<template v-if="company">
<pn-image-selector
key="image"
class="q-mr-sm company-logo"
:size="40"
:iconsize="40"
/>
</template>
<q-input <q-input
key="input"
v-model="company" v-model="company"
dense dense
filled filled
class = "q-mb-md q-mr-md col-grow" class="q-mb-md col-grow company-input"
:label = "$t('account__your_company')" :label="$t('account__your_company')"
:style="{ marginLeft: !company ? '0' : '48px', transition: 'all 0.3s' }"
/> />
<pn-image-selector v-if="company" :size="40" :iconsize="40"/> </q-transition-group>
</div>
<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>

View File

@@ -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>

View File

@@ -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>
<pn-scroll-list>
<account-helper :type /> <account-helper :type />
</pn-scroll-list>
</pn-page-card> </pn-page-card>
</template> </template>

View File

@@ -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) {

View File

@@ -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>

View File

@@ -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
@@ -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>

View File

@@ -14,24 +14,25 @@
</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>
<div class="q-gutter-y-lg w100">
<q-input <q-input
v-model="person.name" v-model="person.name"
dense dense
filled filled
class = "q-my-sm w100" class = "w100"
:label = "$t('person_card__name')" :label = "$t('person_card__name')"
/> />
@@ -41,7 +42,7 @@
: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">
@@ -57,7 +58,7 @@
</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>
@@ -66,7 +67,7 @@
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')"
/> />
@@ -74,7 +75,7 @@
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>
@@ -108,5 +109,5 @@
} }
</script> </script>
<style lang="scss"> <style>
</style> </style>

View File

@@ -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,9 +40,20 @@ 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)
project.value = { ...initial } as Project
originalProject.value = JSON.parse(JSON.stringify(project.value))
} else { } else {
await abort() await abort()
} }

View File

@@ -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'} },
] ]

View File

@@ -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">
<div class="flex items-center">
<q-icon name="mdi-chat-outline"/>
<span>{{ item.chats }} </span> <span>{{ item.chats }} </span>
</div> </div>
<div class="q-mr-sm"> <div class="flex items-center">
<q-icon name="mdi-account-outline" class="q-mx-sm"/> <q-icon name="mdi-account-outline"/>
<span>{{ item.persons }}</span> <span>{{ item.persons }}</span>
</div> </div>
<div class="q-mx-sm">
<q-icon name="mdi-account-group-outline" class="q-mr-sm"/>
<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()

View 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
View 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>

View File

@@ -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)

View File

@@ -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',
name: 'account', path: '/project/:id(\\d+)/company/:companyId',
component: () => import('pages/AccountPage.vue') 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',
path: '/account',
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
View 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
View 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
}
})

View File

@@ -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

View File

@@ -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 при перещелкивании табов

View File

@@ -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/**/*"]
} }