v1.0.2发布,支持视频分析结果查看

This commit is contained in:
BBIT-Kai
2025-06-04 09:36:43 +08:00
parent a3f51dee7d
commit 89adaf02b9
62 changed files with 5975 additions and 4584 deletions
+2 -2
View File
@@ -10,7 +10,7 @@ val postgres_version: String by project
plugins { plugins {
kotlin("jvm") version "2.1.0" kotlin("jvm") version "2.1.0"
id("io.ktor.plugin") version "3.1.0" id("io.ktor.plugin") version "3.1.3"
id("org.jetbrains.kotlin.plugin.serialization") version "2.0.20" id("org.jetbrains.kotlin.plugin.serialization") version "2.0.20"
} }
@@ -80,11 +80,11 @@ dependencies {
implementation("org.jetbrains.exposed:exposed-dao:$exposed_version") implementation("org.jetbrains.exposed:exposed-dao:$exposed_version")
implementation("org.jetbrains.exposed:exposed-jdbc:$exposed_version") implementation("org.jetbrains.exposed:exposed-jdbc:$exposed_version")
implementation("org.jetbrains.exposed:exposed-json:$exposed_version") implementation("org.jetbrains.exposed:exposed-json:$exposed_version")
implementation ("org.jetbrains.exposed:exposed-kotlin-datetime:${exposed_version}")
// MySQL JDBC // MySQL JDBC
// implementation("mysql:mysql-connector-java:8.0.33") // implementation("mysql:mysql-connector-java:8.0.33")
// PostgreSQL JDBC // PostgreSQL JDBC
implementation("org.postgresql:postgresql:$postgres_version") implementation("org.postgresql:postgresql:$postgres_version")
implementation("org.jetbrains.exposed:exposed-kotlin-datetime:$exposed_version")
implementation("ch.qos.logback:logback-classic:$logback_version") implementation("ch.qos.logback:logback-classic:$logback_version")
// Radis // Radis
+1 -1
View File
@@ -3,6 +3,6 @@ ktor_version=3.0.2
kotlin_version=2.1.0 kotlin_version=2.1.0
logback_version=1.4.14 logback_version=1.4.14
kotlinx_html_version=0.10.1 kotlinx_html_version=0.10.1
exposed_version=0.57.0 exposed_version=1.0.0-beta-2
h2_version=2.1.214 h2_version=2.1.214
postgres_version=42.7.4 postgres_version=42.7.4
@@ -2,13 +2,13 @@ package ink.snowflake.server
import com.google.gson.Gson import com.google.gson.Gson
import ink.snowflake.server.plugins.* import ink.snowflake.server.plugins.*
import ink.snowflake.server.route.func.User import ink.snowflake.server.route.User
import ink.snowflake.server.route.func.chat import ink.snowflake.server.route.chat
import ink.snowflake.server.route.configureSockets import ink.snowflake.server.plugins.configureSockets
import ink.snowflake.server.route.func.ImageAnalytics import ink.snowflake.server.route.ImageAnalytics
import ink.snowflake.server.route.func.RemoteDebug import ink.snowflake.server.route.RemoteDebug
import ink.snowflake.server.route.func.VideoAnalytics import ink.snowflake.server.route.VideoAnalytics
import ink.snowflake.server.route.func.VideoAnalyticsJetson import ink.snowflake.server.route.VideoAnalyticsJetson
import ink.snowflake.server.route.mainFunc import ink.snowflake.server.route.mainFunc
import ink.snowflake.server.utils.AppConfig import ink.snowflake.server.utils.AppConfig
import io.ktor.server.application.* import io.ktor.server.application.*
@@ -19,9 +19,14 @@ const val VIDEO_INPUT_PATH = "/tmp/"
/** /**
* 服务器地址 * 服务器地址
* ADB 秦朗FRP地址
*/ */
const val SERVER_PATH = "171.212.101.201" const val SERVER_PATH_FRP = "171.212.101.201"
//const val SERVER_PATH = "localhost" /**
* 服务器地址
* OSS 对象存储服务器地址
*/
const val SERVER_PATH_OSS = "171.212.101.199"
val gson = Gson() val gson = Gson()
@@ -0,0 +1,19 @@
package ink.snowflake.server.database
import ink.snowflake.server.database.table.AIProfile
import ink.snowflake.server.database.table.AIProfilesTable
import ink.snowflake.server.database.table.toAIProfile
import org.jetbrains.exposed.v1.jdbc.selectAll
import org.jetbrains.exposed.v1.jdbc.transactions.transaction
object AIDao {
/**
* 获取所有AI
*/
fun getAllAIProfiles(): List<AIProfile> {
return transaction {
AIProfilesTable.selectAll().map { it.toAIProfile() }
}
}
}
@@ -1,12 +1,14 @@
package ink.snowflake.server.utils.database package ink.snowflake.server.database
import ink.snowflake.server.model.database.* import ink.snowflake.server.database.table.ChatRecord
import ink.snowflake.server.database.table.ChatRecordsTable
import ink.snowflake.server.database.table.toChatRecord
import kotlinx.datetime.Clock import kotlinx.datetime.Clock
import org.jetbrains.exposed.sql.SortOrder import org.jetbrains.exposed.v1.core.SortOrder
import org.jetbrains.exposed.sql.insertAndGetId import org.jetbrains.exposed.v1.datetime.timestampLiteral
import org.jetbrains.exposed.sql.kotlin.datetime.timestampLiteral import org.jetbrains.exposed.v1.jdbc.insertAndGetId
import org.jetbrains.exposed.sql.selectAll import org.jetbrains.exposed.v1.jdbc.selectAll
import org.jetbrains.exposed.sql.transactions.transaction import org.jetbrains.exposed.v1.jdbc.transactions.transaction
object ChatRecordsDao { object ChatRecordsDao {
/** /**
@@ -1,15 +1,14 @@
package ink.snowflake.server.utils.database package ink.snowflake.server.database
import ink.snowflake.server.SERVER_PATH import ink.snowflake.server.SERVER_PATH_OSS
import ink.snowflake.server.model.database.ImageAnalyticsRequest import ink.snowflake.server.model.request.ImageAnalyticsRequest
import ink.snowflake.server.model.database.ImageTable import ink.snowflake.server.database.table.ImageTable
import ink.snowflake.server.model.response.VideoListResponse
import ink.snowflake.server.utils.formatLocalDateTimeToString import ink.snowflake.server.utils.formatLocalDateTimeToString
import kotlinx.datetime.toKotlinLocalDateTime import kotlinx.datetime.toKotlinLocalDateTime
import org.jetbrains.exposed.sql.SortOrder import org.jetbrains.exposed.v1.core.SortOrder
import org.jetbrains.exposed.sql.insert import org.jetbrains.exposed.v1.jdbc.insert
import org.jetbrains.exposed.sql.selectAll import org.jetbrains.exposed.v1.jdbc.selectAll
import org.jetbrains.exposed.sql.transactions.transaction import org.jetbrains.exposed.v1.jdbc.transactions.transaction
import java.sql.Timestamp import java.sql.Timestamp
object ImageDao { object ImageDao {
@@ -41,7 +40,7 @@ object ImageDao {
.orderBy(ImageTable.upload_datetime, SortOrder.DESC) .orderBy(ImageTable.upload_datetime, SortOrder.DESC)
.map { .map {
ImageAnalyticsRequest( ImageAnalyticsRequest(
object_name = "http://${SERVER_PATH}:9000/image/" + it[ImageTable.object_name], object_name = "http://${SERVER_PATH_OSS}:9000/image/" + it[ImageTable.object_name],
upload_datetime = formatLocalDateTimeToString(it[ImageTable.upload_datetime]), upload_datetime = formatLocalDateTimeToString(it[ImageTable.upload_datetime]),
file_name = it[ImageTable.file_name], file_name = it[ImageTable.file_name],
resolution = it[ImageTable.resolution], resolution = it[ImageTable.resolution],
@@ -1,18 +1,14 @@
package ink.snowflake.server.utils.database package ink.snowflake.server.database
import ink.snowflake.server.model.database.ImageTable import ink.snowflake.server.database.table.UserTable
import ink.snowflake.server.model.database.UserTable
import ink.snowflake.server.model.response.UserInfo import ink.snowflake.server.model.response.UserInfo
import ink.snowflake.server.utils.formatLocalDateTimeToString
import kotlinx.datetime.Clock import kotlinx.datetime.Clock
import org.jetbrains.exposed.sql.Op import org.jetbrains.exposed.v1.core.SqlExpressionBuilder.eq
import org.jetbrains.exposed.sql.SqlExpressionBuilder import org.jetbrains.exposed.v1.datetime.timestampLiteral
import org.jetbrains.exposed.sql.SqlExpressionBuilder.eq import org.jetbrains.exposed.v1.jdbc.insertAndGetId
import org.jetbrains.exposed.sql.insertAndGetId import org.jetbrains.exposed.v1.jdbc.selectAll
import org.jetbrains.exposed.sql.kotlin.datetime.timestampLiteral import org.jetbrains.exposed.v1.jdbc.transactions.transaction
import org.jetbrains.exposed.sql.select
import org.jetbrains.exposed.sql.selectAll
import org.jetbrains.exposed.sql.transactions.transaction
object UserDAO { object UserDAO {
/** /**
@@ -82,7 +78,8 @@ object UserDAO {
UserInfo( UserInfo(
username = it[UserTable.username], username = it[UserTable.username],
email = it[UserTable.email], email = it[UserTable.email],
phone = it[UserTable.phone] phone = it[UserTable.phone],
roles = it[UserTable.roles]?.removePrefix("{")!!.removeSuffix("}").split(","),
) )
} }
.singleOrNull() .singleOrNull()
@@ -1,20 +1,20 @@
package ink.snowflake.server.utils.database package ink.snowflake.server.database
import ink.snowflake.server.model.database.VideoAnalyticsDetailTable import ink.snowflake.server.database.table.VideoAnalyticsDetailTable
import ink.snowflake.server.model.database.VideoTable import ink.snowflake.server.database.table.VideoTable
import ink.snowflake.server.model.database.VideoTable.vATime import ink.snowflake.server.database.table.VideoTable.vATime
import ink.snowflake.server.model.database.VideoTable.vName import ink.snowflake.server.database.table.VideoTable.vName
import ink.snowflake.server.model.request.VideoAnalyticsRequest import ink.snowflake.server.model.request.VideoAnalyticsRequest
import ink.snowflake.server.model.request.VideoDetail import ink.snowflake.server.model.request.VideoDetail
import ink.snowflake.server.model.response.VideoListResponse import ink.snowflake.server.model.response.VideoListResponse
import ink.snowflake.server.utils.formatLocalDateTimeToString import ink.snowflake.server.utils.formatLocalDateTimeToString
import kotlinx.datetime.toKotlinLocalDateTime import kotlinx.datetime.toKotlinLocalDateTime
import org.jetbrains.exposed.sql.ResultRow import org.jetbrains.exposed.v1.core.ResultRow
import org.jetbrains.exposed.sql.SortOrder import org.jetbrains.exposed.v1.core.SortOrder
import org.jetbrains.exposed.sql.insert import org.jetbrains.exposed.v1.jdbc.insert
import org.jetbrains.exposed.sql.insertAndGetId import org.jetbrains.exposed.v1.jdbc.insertAndGetId
import org.jetbrains.exposed.sql.selectAll import org.jetbrains.exposed.v1.jdbc.selectAll
import org.jetbrains.exposed.sql.transactions.transaction import org.jetbrains.exposed.v1.jdbc.transactions.transaction
import java.sql.Timestamp import java.sql.Timestamp
object VideoDao { object VideoDao {
@@ -54,27 +54,32 @@ object VideoDao {
} }
} }
fun getVideoList():List<VideoListResponse> { fun getVideoList(name: String?): List<VideoListResponse> {
return transaction { return transaction {
VideoTable.selectAll() val query = VideoTable.selectAll()
.orderBy(vATime, SortOrder.DESC) // 动态加 where 条件
if (!name.isNullOrBlank()) {
query.where { VideoTable.vName like "%${name}%" }
}
query.orderBy(VideoTable.vATime, SortOrder.DESC)
.map { .map {
VideoListResponse( VideoListResponse(
v_id = it[VideoTable.id].value, v_id = it[VideoTable.id].value,
v_name = it[vName], v_name = it[VideoTable.vName],
v_a_time = formatLocalDateTimeToString(it[vATime]) v_a_time = formatLocalDateTimeToString(it[VideoTable.vATime])
) )
} }
} }
} }
fun getAnalyticsDetailByVideoId(vId: Int): ResultRow? { fun getAnalyticsDetailByVideoId(vId: Int): ResultRow? {
return transaction { return transaction {
VideoTable.selectAll().where { VideoTable.id eq vId }.singleOrNull() VideoTable.selectAll().where { VideoTable.id eq vId }.singleOrNull()
} }
} }
fun selectVideoDetailByVid(vId:Int): List<VideoDetail>{ fun selectVideoDetailByVid(vId: Int): List<VideoDetail> {
return transaction { return transaction {
VideoAnalyticsDetailTable VideoAnalyticsDetailTable
.selectAll() .selectAll()
@@ -1,12 +1,12 @@
package ink.snowflake.server.model.database package ink.snowflake.server.database.table
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import org.jetbrains.exposed.dao.id.IntIdTable
import org.jetbrains.exposed.sql.*
import org.jetbrains.exposed.sql.json.json
import org.jetbrains.exposed.sql.kotlin.datetime.timestamp
import com.google.gson.reflect.TypeToken import com.google.gson.reflect.TypeToken
import ink.snowflake.server.gson import ink.snowflake.server.gson
import org.jetbrains.exposed.v1.core.ResultRow
import org.jetbrains.exposed.v1.core.dao.id.IntIdTable
import org.jetbrains.exposed.v1.datetime.timestamp
import org.jetbrains.exposed.v1.json.json
// 定义 AI 表 // 定义 AI 表
object AIProfilesTable : IntIdTable("ai_profiles") { object AIProfilesTable : IntIdTable("ai_profiles") {
@@ -1,10 +1,9 @@
package ink.snowflake.server.model.database package ink.snowflake.server.database.table
import kotlinx.datetime.LocalDateTime import org.jetbrains.exposed.v1.core.Column
import org.jetbrains.exposed.dao.id.IntIdTable import org.jetbrains.exposed.v1.core.ResultRow
import org.jetbrains.exposed.sql.kotlin.datetime.timestamp import org.jetbrains.exposed.v1.core.dao.id.IntIdTable
import org.jetbrains.exposed.sql.Column import org.jetbrains.exposed.v1.datetime.timestamp
import org.jetbrains.exposed.sql.ResultRow
object ChatRecordsTable : IntIdTable("chat_records") { object ChatRecordsTable : IntIdTable("chat_records") {
// 用户 ID(外键) // 用户 ID(外键)
@@ -1,11 +1,10 @@
package ink.snowflake.server.model.database package ink.snowflake.server.database.table
import com.google.gson.reflect.TypeToken import com.google.gson.reflect.TypeToken
import ink.snowflake.server.gson import ink.snowflake.server.gson
import org.jetbrains.exposed.dao.id.EntityID import org.jetbrains.exposed.v1.core.dao.id.IntIdTable
import org.jetbrains.exposed.dao.id.IntIdTable import org.jetbrains.exposed.v1.datetime.datetime
import org.jetbrains.exposed.sql.json.json import org.jetbrains.exposed.v1.json.json
import org.jetbrains.exposed.sql.kotlin.datetime.datetime
object ImageTable : IntIdTable("image") { object ImageTable : IntIdTable("image") {
val object_name = varchar("object_name", 255) val object_name = varchar("object_name", 255)
@@ -1,9 +1,7 @@
package ink.snowflake.server.model.database package ink.snowflake.server.database.table
import org.jetbrains.exposed.dao.id.IntIdTable
import org.jetbrains.exposed.sql.Column
import org.jetbrains.exposed.sql.kotlin.datetime.timestamp
import java.time.LocalDateTime
import org.jetbrains.exposed.v1.core.dao.id.IntIdTable
import org.jetbrains.exposed.v1.datetime.timestamp
object UserTable : IntIdTable("users") { object UserTable : IntIdTable("users") {
// 用户名 // 用户名
@@ -26,4 +24,7 @@ object UserTable : IntIdTable("users") {
// 更新时间 // 更新时间
val updatedAt = timestamp("updated_at").nullable() val updatedAt = timestamp("updated_at").nullable()
// 角色
val roles = text("roles").nullable()
} }
@@ -1,6 +1,6 @@
package ink.snowflake.server.model.database package ink.snowflake.server.database.table
import org.jetbrains.exposed.dao.id.IntIdTable import org.jetbrains.exposed.v1.core.dao.id.IntIdTable
object VideoAnalyticsDetailTable : IntIdTable("video_analytics_detail") { object VideoAnalyticsDetailTable : IntIdTable("video_analytics_detail") {
@@ -1,8 +1,7 @@
package ink.snowflake.server.model.database package ink.snowflake.server.database.table
import org.jetbrains.exposed.dao.id.EntityID import org.jetbrains.exposed.v1.core.dao.id.IntIdTable
import org.jetbrains.exposed.dao.id.IntIdTable import org.jetbrains.exposed.v1.datetime.datetime
import org.jetbrains.exposed.sql.kotlin.datetime.datetime
object VideoTable : IntIdTable("video") { object VideoTable : IntIdTable("video") {
@@ -0,0 +1,19 @@
package ink.snowflake.server.model
import kotlinx.serialization.Serializable
import kotlinx.serialization.KSerializer
import kotlinx.serialization.descriptors.*
import kotlinx.serialization.encoding.*
import java.util.UUID
object UUIDSerializer : KSerializer<UUID> {
override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("UUID", PrimitiveKind.STRING)
override fun serialize(encoder: Encoder, value: UUID) {
encoder.encodeString(value.toString())
}
override fun deserialize(decoder: Decoder): UUID {
return UUID.fromString(decoder.decodeString())
}
}
@@ -1,4 +1,4 @@
package ink.snowflake.server.model.ai package ink.snowflake.server.model.request
import com.google.gson.annotations.SerializedName import com.google.gson.annotations.SerializedName
@@ -17,5 +17,3 @@ data class ChatRequest(
val role: String = "user" val role: String = "user"
) )
} }
@@ -1,4 +1,4 @@
package ink.snowflake.server.model.database package ink.snowflake.server.model.request
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
@@ -1,4 +1,4 @@
package ink.snowflake.server.model.ai package ink.snowflake.server.model.response
import com.google.gson.annotations.SerializedName import com.google.gson.annotations.SerializedName
@@ -33,5 +33,3 @@ data class ChatResponse(
val role: String = "" val role: String = ""
) )
} }
@@ -4,6 +4,7 @@ import kotlinx.serialization.Serializable
@Serializable @Serializable
data class VideoAnalyticsDetail( data class VideoAnalyticsDetail(
val v_id: Int,
val v_name: String, val v_name: String,
val v_video_play_path: String, val v_video_play_path: String,
val v_file_name: String, val v_file_name: String,
@@ -1,7 +1,7 @@
package ink.snowflake.server.plugins package ink.snowflake.server.plugins
import ink.snowflake.server.utils.AppConfig import ink.snowflake.server.utils.AppConfig
import org.jetbrains.exposed.sql.* import org.jetbrains.exposed.v1.jdbc.Database
fun configureDatabases(config: AppConfig) { fun configureDatabases(config: AppConfig) {
Database.connect( Database.connect(
@@ -6,8 +6,6 @@ import io.ktor.server.auth.*
import io.ktor.server.http.content.* import io.ktor.server.http.content.*
import io.ktor.server.plugins.autohead.AutoHeadResponse import io.ktor.server.plugins.autohead.AutoHeadResponse
import io.ktor.server.plugins.partialcontent.PartialContent import io.ktor.server.plugins.partialcontent.PartialContent
import io.ktor.server.routing.*
import org.jetbrains.exposed.sql.*
import java.io.File import java.io.File
fun Application.configureStaticPath() { fun Application.configureStaticPath() {
@@ -72,7 +72,8 @@ fun Application.configureStatusPages() {
status(HttpStatusCode.InternalServerError) { call, status -> status(HttpStatusCode.InternalServerError) { call, status ->
val response = BaseResponse<Nothing>( val response = BaseResponse<Nothing>(
status = false, status = false,
message = "${status.value} Internal Server Error," + status.description // message = "${status.value} Internal Server Error" + status.description
message = "${status.value} Internal Server Error"
) )
call.respond(status, response) call.respond(status, response)
} }
@@ -1,4 +1,4 @@
package ink.snowflake.server.route package ink.snowflake.server.plugins
import io.ktor.serialization.gson.* import io.ktor.serialization.gson.*
import io.ktor.server.application.* import io.ktor.server.application.*
@@ -1,32 +0,0 @@
package ink.snowflake.server.repository
import ink.snowflake.server.model.database.ImageAnalyticsRequest
import ink.snowflake.server.model.database.VideoTable
import ink.snowflake.server.model.database.VideoTable.vAAverageMaskedRatio
import ink.snowflake.server.model.database.VideoTable.vACountPeople
import ink.snowflake.server.model.database.VideoTable.vAMaxAction
import ink.snowflake.server.model.database.VideoTable.vAMaxStayTime
import ink.snowflake.server.model.database.VideoTable.vATotalPeople
import ink.snowflake.server.model.database.VideoTable.vStartDateTime
import ink.snowflake.server.model.response.Area
import ink.snowflake.server.model.response.DetailItem
import ink.snowflake.server.model.response.ItemStyle
import ink.snowflake.server.model.response.VideoAnalyticsData
import ink.snowflake.server.model.response.VideoAnalyticsDetail
import ink.snowflake.server.model.response.VideoListResponse
import ink.snowflake.server.utils.database.ImageDao
import ink.snowflake.server.utils.database.VideoDao
import kotlinx.datetime.LocalDateTime
import kotlinx.datetime.toJavaLocalDateTime
import java.time.format.DateTimeFormatter
class ImageDataBase {
fun saveImageAnalyticsData(request: ImageAnalyticsRequest) {
return ImageDao.insertImageAnalyticsData(request)
}
fun getImageList(): List<ImageAnalyticsRequest> {
return ImageDao.getVideoList()
}
}
@@ -1,49 +0,0 @@
package ink.snowflake.server.repository
import ink.snowflake.server.utils.database.UserDAO
import java.security.MessageDigest
import kotlin.text.Charsets.UTF_8
class UserDataBase {
fun login(email: String, password: String): Int {
// 查找用户
val userId = UserDAO.getUserIdByEmail(email)
return if(userId == null){
// 账号不存在
-1
}else{
val userPassword = UserDAO.getPasswordById(userId)
// 验证密码
if (password == userPassword) {
// 登录成功
userId
} else {
// 账号密码不匹配
-2
}
}
}
/**
* 正数:userId 负数:错误码
*/
fun registerByEmail(email: String, password: String): Int {
// 查找用户
val userId = UserDAO.getUserIdByEmail(email)
return if(userId != null){
// 如果用户已存在,返回 0
0
}else{
// 用户不存在,插入新用户
UserDAO.registerByEmailAndGetId(email, password)
}
}
fun hashPassword(password: String): String {
val bytes = MessageDigest.getInstance("SHA-256").digest(password.toByteArray(UTF_8))
return bytes.joinToString("") { "%02x".format(it) }
}
}
@@ -1,158 +0,0 @@
package ink.snowflake.server.repository
import ink.snowflake.server.SERVER_PATH
import ink.snowflake.server.model.database.VideoTable
import ink.snowflake.server.model.database.VideoTable.vAAverageMaskedRatio
import ink.snowflake.server.model.database.VideoTable.vACountPeople
import ink.snowflake.server.model.database.VideoTable.vAMaxAction
import ink.snowflake.server.model.database.VideoTable.vAMaxStayTime
import ink.snowflake.server.model.database.VideoTable.vATotalPeople
import ink.snowflake.server.model.database.VideoTable.vStartDateTime
import ink.snowflake.server.model.response.Area
import ink.snowflake.server.model.response.DetailItem
import ink.snowflake.server.model.response.ItemStyle
import ink.snowflake.server.model.response.VideoAnalyticsData
import ink.snowflake.server.model.response.VideoAnalyticsDetail
import ink.snowflake.server.model.request.VideoAnalyticsRequest
import ink.snowflake.server.model.response.VideoListResponse
import ink.snowflake.server.utils.database.VideoDao
import kotlinx.datetime.LocalDateTime
import kotlinx.datetime.toJavaLocalDateTime
import java.time.format.DateTimeFormatter
class VideoDataBase {
fun saveVideoAnalyticsData(request: VideoAnalyticsRequest) {
return VideoDao.insertVideoAnalyticsData(request)
}
fun getVideoList(): List<VideoListResponse> {
return VideoDao.getVideoList()
}
fun getAnalyticsDetailByVideoId(vId: Int): VideoAnalyticsDetail? {
// 查询视频信息
val video = VideoDao.getAnalyticsDetailByVideoId(vId)
if (video == null) {
return null
}
// 查询相关的分析详情
val details = VideoDao.selectVideoDetailByVid(vId)
// 用于返回的数据
val yTotalData = mutableListOf<Pair<String, Int>>() // (时间, 总人数)
val yMaskedData = mutableListOf<Pair<String, Int>>() // (时间, 佩戴口罩人数)
val areaData = mutableListOf<List<Area>>()
val detailList = mutableListOf<DetailItem>()
// 颜色映射
val colors = mapOf(
"feed" to "rgba(0, 255, 0, 0.4)", // 淡绿色
"disinfection" to "rgba(0, 0, 255, 0.4)", // 淡蓝色
"other" to "rgba(255, 0, 0, 0.4)" // 淡红色
)
// 生成 yTotalData 和 yMaskedData,同时计算 areaData
var lastAction: String? = null
var areaStartTime: String? = null
var currentColor: String? = null
for (detail in details) {
// 获取视频开始时间
val vStartDatetime: LocalDateTime = video[vStartDateTime]!!
val timeStr = vStartDatetime.toJavaLocalDateTime()
.plusSeconds(detail.a_time_stamp.toLong())
.format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"))
// 添加总人数和口罩佩戴人数
yTotalData.add(timeStr to detail.a_total_people)
yMaskedData.add(timeStr to detail.a_total_people_masked)
// 处理 areaData,根据 a_action 判断是否需要创建新的区域
if (detail.a_action != "--") { // 只处理非 "--" 动作
if (lastAction == null || lastAction != detail.a_action) {
// 如果上一个动作和当前动作不同,并且 areaStartTime 已经存在,创建新的区域
if (areaStartTime != null) {
areaData.add(
listOf(
Area(areaStartTime, ItemStyle(currentColor ?: "#FF0000")),
Area(timeStr, ItemStyle(currentColor ?: "#FF0000"))
)
)
}
// 添加到 detailList,记录动作开始的时刻
detailList.add(
DetailItem(getFriendlyActionName(detail.a_action), time = timeStr)
)
// 更新为新的动作
lastAction = detail.a_action
areaStartTime = timeStr
currentColor = colors[detail.a_action] ?: "#FF0000" // 默认红色
}
} else {
// 如果当前动作为 "--",则结束当前区域,并重置状态
if (areaStartTime != null) {
// 添加到 detailList,记录动作开始的时刻
detailList.add(
DetailItem(getFriendlyActionName(detail.a_action), timeStr)
)
// 结束当前区域
areaData.add(
listOf(
Area(areaStartTime, ItemStyle(currentColor ?: "#FF0000")),
Area(timeStr, ItemStyle(currentColor ?: "#FF0000"))
)
)
// 重置状态
lastAction = null
areaStartTime = null
currentColor = null
}
}
}
// 处理最后一个区域的结束时间
if (areaStartTime != null && lastAction != null) {
areaData.add(
listOf(
Area(areaStartTime, ItemStyle(currentColor ?: "#FF0000")),
Area(yTotalData.last().first, ItemStyle(currentColor ?: "#FF0000"))
)
)
}
val analyticsData = VideoAnalyticsData(
yTotalData = yTotalData,
yMaskedData = yMaskedData,
areaData = areaData
)
// 返回数据
return VideoAnalyticsDetail(
v_name = video[VideoTable.vName],
v_video_play_path = "http://${SERVER_PATH}:9000/video/" + video[VideoTable.vObjectName],
v_file_name = video[VideoTable.vFileName],
v_duration = video[VideoTable.vDuration],
v_size = video[VideoTable.vSize],
v_start_datetime = video[VideoTable.vStartDateTime]?.toString() ?: "",
v_video_codec = video[VideoTable.vVideoCodec],
v_audio_codec = video[VideoTable.vAudioCodec],
v_overall_bit_rate = video[VideoTable.vOverallBitRate],
v_resolution = video[VideoTable.vResolution],
v_a_time = video[VideoTable.vATime].toString(),
v_a_total_people = video[vATotalPeople], // 总人数
v_a_count_people = video[vACountPeople], // 佩戴口罩人数
v_a_max_stay_time = video[vAMaxStayTime], // 最大停留时间
v_a_max_action = getFriendlyActionName(video[vAMaxAction]), // 最大动作
v_a_average_masked_ratio = video[vAAverageMaskedRatio], // 平均佩戴口罩比例
v_a_details = analyticsData, // 这里是计算得来的 VideoAnalyticsData
v_details_list = detailList
)
}
}
private fun getFriendlyActionName(name: String): String {
return if (name == "feed") {
"喂桑"
} else if (name == "disinfection") {
"消毒"
} else {
name
}
}
@@ -1,12 +1,12 @@
package ink.snowflake.server.route.func package ink.snowflake.server.route
import com.cyberecho.mdoel.database.WSChatRecords import com.cyberecho.mdoel.database.WSChatRecords
import ink.snowflake.server.gson import ink.snowflake.server.gson
import ink.snowflake.server.model.ai.ChatRequest import ink.snowflake.server.model.request.ChatRequest
import ink.snowflake.server.model.ai.ChatResponse import ink.snowflake.server.model.response.ChatResponse
import ink.snowflake.server.model.response.BaseResponse import ink.snowflake.server.model.response.BaseResponse
import ink.snowflake.server.utils.WebSocketManager import ink.snowflake.server.utils.WebSocketManager
import ink.snowflake.server.utils.database.ChatRecordsDao import ink.snowflake.server.database.ChatRecordsDao
import ink.snowflake.server.utils.getUserIdByToken import ink.snowflake.server.utils.getUserIdByToken
import io.ktor.client.* import io.ktor.client.*
import io.ktor.client.engine.cio.* import io.ktor.client.engine.cio.*
@@ -1,24 +1,21 @@
package ink.snowflake.server.route.func package ink.snowflake.server.route
import ink.snowflake.server.model.database.ImageAnalyticsRequest import ink.snowflake.server.model.request.ImageAnalyticsRequest
import ink.snowflake.server.model.request.VideoAnalyticsRequest
import ink.snowflake.server.model.response.* import ink.snowflake.server.model.response.*
import ink.snowflake.server.repository.ImageDataBase import ink.snowflake.server.database.ImageDao
import io.ktor.server.application.* import io.ktor.server.application.*
import io.ktor.server.auth.* import io.ktor.server.auth.*
import io.ktor.server.routing.* import io.ktor.server.routing.*
import io.ktor.server.request.* import io.ktor.server.request.*
import io.ktor.server.response.* import io.ktor.server.response.*
import io.ktor.server.websocket.*
fun Application.ImageAnalytics() { fun Application.ImageAnalytics() {
val repository = ImageDataBase()
routing { routing {
route("/api") { route("/api") {
// 上传分析结果 // 上传分析结果
post("/saveImageAnalyticsData") { post("/saveImageAnalyticsData") {
val request = call.receive<ImageAnalyticsRequest>() val request = call.receive<ImageAnalyticsRequest>()
call.respond(BaseResponse(data = repository.saveImageAnalyticsData(request))) call.respond(BaseResponse(data = ImageDao.insertImageAnalyticsData(request)))
} }
authenticate { authenticate {
// 拍照保存为图片 并且调用Python程序进行分析 // 拍照保存为图片 并且调用Python程序进行分析
@@ -33,7 +30,7 @@ fun Application.ImageAnalytics() {
} }
// 获取已分析视频列表 // 获取已分析视频列表
get("/getImageList") { get("/getImageList") {
val res = repository.getImageList() val res = ImageDao.getVideoList()
call.respond(BaseResponse(data = res)) call.respond(BaseResponse(data = res))
} }
} }
@@ -1,7 +1,7 @@
package ink.snowflake.server.route.func package ink.snowflake.server.route
import com.google.gson.Gson import com.google.gson.Gson
import ink.snowflake.server.SERVER_PATH import ink.snowflake.server.SERVER_PATH_FRP
import ink.snowflake.server.model.request.DevicesInfo import ink.snowflake.server.model.request.DevicesInfo
import ink.snowflake.server.model.response.* import ink.snowflake.server.model.response.*
import ink.snowflake.server.utils.runCommand import ink.snowflake.server.utils.runCommand
@@ -13,14 +13,11 @@ import io.ktor.client.plugins.auth.providers.basic
import io.ktor.client.request.get import io.ktor.client.request.get
import io.ktor.client.statement.HttpResponse import io.ktor.client.statement.HttpResponse
import io.ktor.client.statement.bodyAsText import io.ktor.client.statement.bodyAsText
import io.ktor.http.content.*
import io.ktor.server.application.* import io.ktor.server.application.*
import io.ktor.server.auth.authenticate import io.ktor.server.auth.authenticate
import io.ktor.server.routing.* import io.ktor.server.routing.*
import io.ktor.server.request.*
import io.ktor.server.response.* import io.ktor.server.response.*
import io.ktor.server.websocket.* import io.ktor.server.websocket.*
import io.ktor.utils.io.*
import io.ktor.websocket.* import io.ktor.websocket.*
import io.ktor.websocket.send import io.ktor.websocket.send
import kotlinx.coroutines.channels.consumeEach import kotlinx.coroutines.channels.consumeEach
@@ -59,7 +56,7 @@ fun Application.RemoteDebug() {
get("/connectLocalDevice") { get("/connectLocalDevice") {
val port = call.parameters["port"] val port = call.parameters["port"]
if (port != null) { if (port != null) {
val result = runAdbCommand("connect ${SERVER_PATH}:$port") val result = runAdbCommand("connect ${SERVER_PATH_FRP}:$port")
call.respond(BaseResponse(data = result)) call.respond(BaseResponse(data = result))
} else { } else {
call.respond(BaseResponse(status = false, message = "IP或端口无效", data = null)) call.respond(BaseResponse(status = false, message = "IP或端口无效", data = null))
@@ -1,16 +1,14 @@
package ink.snowflake.server.route.func package ink.snowflake.server.route
import com.auth0.jwt.JWT import com.auth0.jwt.JWT
import com.auth0.jwt.algorithms.Algorithm import com.auth0.jwt.algorithms.Algorithm
import ink.snowflake.server.utils.database.AIDao
import ink.snowflake.server.model.request.CommonRequest import ink.snowflake.server.model.request.CommonRequest
import ink.snowflake.server.model.request.LoginRequest import ink.snowflake.server.model.request.LoginRequest
import ink.snowflake.server.model.request.RefreshTokenRequest import ink.snowflake.server.model.request.RefreshTokenRequest
import ink.snowflake.server.model.request.RegisterRequest import ink.snowflake.server.model.request.RegisterRequest
import ink.snowflake.server.model.response.* import ink.snowflake.server.model.response.*
import ink.snowflake.server.repository.UserDataBase
import ink.snowflake.server.utils.AppConfig import ink.snowflake.server.utils.AppConfig
import ink.snowflake.server.utils.database.UserDAO import ink.snowflake.server.database.UserDAO
import ink.snowflake.server.utils.getUserIdByToken import ink.snowflake.server.utils.getUserIdByToken
import io.ktor.server.application.* import io.ktor.server.application.*
import io.ktor.server.auth.* import io.ktor.server.auth.*
@@ -22,12 +20,15 @@ import org.redisson.Redisson
import org.redisson.api.RBucket import org.redisson.api.RBucket
import org.redisson.api.RedissonClient import org.redisson.api.RedissonClient
import org.redisson.config.Config import org.redisson.config.Config
import java.security.MessageDigest
import java.text.DateFormat import java.text.DateFormat
import java.time.Duration import java.time.Duration
import java.util.* import java.util.*
import javax.mail.* import javax.mail.*
import javax.mail.internet.InternetAddress import javax.mail.internet.InternetAddress
import javax.mail.internet.MimeMessage import javax.mail.internet.MimeMessage
import kotlin.collections.joinToString
import kotlin.text.Charsets.UTF_8
// 配置和初始化 Redis 客户端 // 配置和初始化 Redis 客户端
fun setupRedis(): RedissonClient { fun setupRedis(): RedissonClient {
@@ -37,7 +38,6 @@ fun setupRedis(): RedissonClient {
} }
fun Application.User(config: AppConfig) { fun Application.User(config: AppConfig) {
val repository = UserDataBase()
// 初始化 Redis 连接 // 初始化 Redis 连接
val redisClient: RedissonClient = setupRedis() val redisClient: RedissonClient = setupRedis()
routing { routing {
@@ -47,21 +47,33 @@ fun Application.User(config: AppConfig) {
val loginRequest = call.receive<LoginRequest>() val loginRequest = call.receive<LoginRequest>()
val email = loginRequest.account val email = loginRequest.account
val password = loginRequest.password val password = loginRequest.password
val userId = repository.login(email, password) // 查找用户
call.respond( val userId = UserDAO.getUserIdByEmail(email)
if (userId == -1) { val res = if(userId == null){
BaseResponse(status = false, message = "尚未注册", data = null) // 账号不存在
} else if (userId == -2) { -1
BaseResponse(status = false, message = "账号密码不匹配,请重新登录", data = null) }else{
val userPassword = UserDAO.getPasswordById(userId)
// 验证密码
if (password == userPassword) {
// 登录成功
userId
} else { } else {
BaseResponse( // 账号密码不匹配
-2
}
}
call.respond(
when (res) {
-1 -> BaseResponse(status = false, message = "尚未注册", data = null)
-2 -> BaseResponse(status = false, message = "账号密码不匹配,请重新登录", data = null)
else -> BaseResponse(
status = true, data = LoginResponse( status = true, data = LoginResponse(
userId.toString(), userId.toString(),
generateAccessToken(config, userId), generateAccessToken(config, res),
generateRefreshToken(config, userId) generateRefreshToken(config, res)
) )
) )
// match == -1
} }
) )
} }
@@ -91,16 +103,24 @@ fun Application.User(config: AppConfig) {
} else if (storedCode != register.code) { } else if (storedCode != register.code) {
call.respond(BaseResponse(status = false, message = "验证码错误", data = null)) call.respond(BaseResponse(status = false, message = "验证码错误", data = null))
} else { } else {
val userId = repository.registerByEmail(register.account, register.password) // 查找用户
if (userId > 0) { val userId = UserDAO.getUserIdByEmail(register.account)
val res = if(userId != null){
// 如果用户已存在,返回 0
0
}else{
// 用户不存在,插入新用户
UserDAO.registerByEmailAndGetId(register.account,register.password)
}
if (res > 0) {
call.respond( call.respond(
BaseResponse( BaseResponse(
status = true, message = "注册成功", data = status = true, message = "注册成功", data =
BaseResponse( BaseResponse(
status = true, data = LoginResponse( status = true, data = LoginResponse(
account, account,
generateAccessToken(config, userId), generateAccessToken(config, res),
generateRefreshToken(config, userId) generateRefreshToken(config, res)
) )
) )
) )
@@ -254,3 +274,9 @@ fun sendVerificationEmail(config: AppConfig, recipientEmail: String, verificatio
println("Failed to send verification email.") println("Failed to send verification email.")
} }
} }
fun hashPassword(password: String): String {
val bytes = MessageDigest.getInstance("SHA-256").digest(password.toByteArray(UTF_8))
return bytes.joinToString("") { "%02x".format(it) }
}
@@ -0,0 +1,297 @@
package ink.snowflake.server.route
import ink.snowflake.server.SERVER_PATH_OSS
import ink.snowflake.server.VIDEO_INPUT_PATH
import ink.snowflake.server.database.table.VideoTable
import ink.snowflake.server.database.table.VideoTable.vAAverageMaskedRatio
import ink.snowflake.server.database.table.VideoTable.vACountPeople
import ink.snowflake.server.database.table.VideoTable.vAMaxAction
import ink.snowflake.server.database.table.VideoTable.vAMaxStayTime
import ink.snowflake.server.database.table.VideoTable.vATotalPeople
import ink.snowflake.server.database.table.VideoTable.vStartDateTime
import ink.snowflake.server.model.request.VideoAnalyticsRequest
import ink.snowflake.server.model.response.*
import ink.snowflake.server.utils.WebSocketManager.broadcastMessage
import ink.snowflake.server.database.VideoDao
import ink.snowflake.server.utils.runCommand
import io.ktor.http.content.*
import io.ktor.server.application.*
import io.ktor.server.auth.*
import io.ktor.server.routing.*
import io.ktor.server.request.*
import io.ktor.server.response.*
import io.ktor.server.websocket.*
import io.ktor.websocket.*
import kotlinx.coroutines.channels.consumeEach
import kotlinx.datetime.LocalDateTime
import kotlinx.datetime.toJavaLocalDateTime
import java.io.File
import java.io.IOException
import java.time.format.DateTimeFormatter
import java.util.*
val clients = Collections.synchronizedList<WebSocketServerSession>(ArrayList()) // 线程安全的客户端列表
var aiState = "等待分析任务中"
fun Application.VideoAnalytics() {
routing {
// 实时发送AI状态
webSocket("/handleState") { // WebSocket 路由
clients.add(this) // 添加当前连接的客户端
send(aiState) // 向客户端发送连接成功消息
try {
incoming.consumeEach { frame -> // 持续接收消息
when (frame) {
is Frame.Text -> {
aiState = frame.readText() // 更新状态
broadcastMessage(aiState) // 使用封装的方法广播消息
}
is Frame.Close -> {
println("Closed")
clients.remove(this)
close() // 确保关闭 WebSocket 连接
return@consumeEach
}
// 其他消息类型的处理
is Frame.Binary -> TODO() // 处理二进制消息
is Frame.Ping -> TODO() // 处理 Ping 消息
is Frame.Pong -> TODO() // 处理 Pong 消息
}
}
} catch (e: Exception) {
// 处理接收消息时的异常
close(CloseReason(CloseReason.Codes.NORMAL, "Client disconnected"))
e.printStackTrace()
} finally {
clients.remove(this) // 确保在连接关闭时移除客户端
}
}
route("/api") {
route("/iva") {
// 上传分析结果
post("/saveVideoAnalyticsData") {
val request = call.receive<VideoAnalyticsRequest>()
// todo 上传这里未做测试
call.respond(BaseResponse(data = VideoDao.insertVideoAnalyticsData(request)))
}
authenticate {
post("/upload") {
val multipart = call.receiveMultipart() //1G
// 确保 uploads 目录存在
val uploadDir = File(VIDEO_INPUT_PATH)
if (!uploadDir.exists()) {
if (!uploadDir.mkdirs()) {
println("无法创建目录")
throw IOException("Failed to create upload directory.")
}
}
var fileName = ""
var name = ""
var datetime = ""
broadcastMessage("正在上传数据")
multipart.forEachPart { part ->
when (part) {
is PartData.FileItem -> {
fileName = part.originalFileName ?: "unknown"
val file = File("$VIDEO_INPUT_PATH$fileName") // 保存路径
//ktor3
// file.outputStream().use { outputStream ->
// val writableChannel = Channels.newChannel(outputStream)
// part.provider().copyTo(writableChannel) // 复制到 WritableByteChannel
// }
//ktor2
part.streamProvider().use { inputStream ->
file.outputStream().buffered().use { outputStream ->
inputStream.copyTo(outputStream)
}
}
}
is PartData.FormItem -> {
when (part.name) {
"name" -> name = part.value
"datetime" -> datetime = part.value
}
}
else -> part.dispose()
}
}
call.respond(BaseResponse(message = "上传成功", data = null))
broadcastMessage("上传完成,开始启动AI引擎")
val command = listOf(
"/usr/bin/python3",
"/home/xhcp/mine/IntelligentVideoAnalytics/AI_Project/DeepStream_Action_Recognition/core/final.py",
"$VIDEO_INPUT_PATH$fileName",
datetime,
name
)
println("-----------------" + command.joinToString(" "))
runCommand(command) {
println(it)
}
}
// 获取已分析视频列表
get("/getVideoList") {
val name = call.parameters["name"]
val res = VideoDao.getVideoList(name)
call.respond(BaseResponse(data = res))
}
// 获取某视频分析详情
get("/getAnalyticsDetailByVideoId") {
// 获取 vId 参数
val vIdParam = call.parameters["vId"]
val vId = vIdParam?.toIntOrNull() // 将 vId 转换为 Int,确保安全
if (vId == null) {
call.respond(BaseResponse(status = true, message = "Invalid vId", data = null))
return@get
}
// 查询视频信息
val video = VideoDao.getAnalyticsDetailByVideoId(vId)
if (video == null) {
call.respond(BaseResponse(status = false, message = "不存在该视频", data = null))
} else {
// 查询相关的分析详情
val details = VideoDao.selectVideoDetailByVid(vId)
// 用于返回的数据
val yTotalData = mutableListOf<Pair<String, Int>>() // (时间, 总人数)
val yMaskedData = mutableListOf<Pair<String, Int>>() // (时间, 佩戴口罩人数)
val areaData = mutableListOf<List<Area>>()
val detailList = mutableListOf<DetailItem>()
// 颜色映射
val colors = mapOf(
"feed" to "rgba(0, 255, 0, 0.4)", // 淡绿色
"disinfection" to "rgba(0, 0, 255, 0.4)", // 淡蓝色
"other" to "rgba(255, 0, 0, 0.4)" // 淡红色
)
// 生成 yTotalData 和 yMaskedData,同时计算 areaData
var lastAction: String? = null
var areaStartTime: String? = null
var currentColor: String? = null
for (detail in details) {
// 获取视频开始时间
val vStartDatetime: LocalDateTime = video[vStartDateTime]!!
val timeStr = vStartDatetime.toJavaLocalDateTime()
.plusSeconds(detail.a_time_stamp.toLong())
.format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"))
// 添加总人数和口罩佩戴人数
yTotalData.add(timeStr to detail.a_total_people)
yMaskedData.add(timeStr to detail.a_total_people_masked)
// 处理 areaData,根据 a_action 判断是否需要创建新的区域
if (detail.a_action != "--") { // 只处理非 "--" 动作
if (lastAction == null || lastAction != detail.a_action) {
// 如果上一个动作和当前动作不同,并且 areaStartTime 已经存在,创建新的区域
if (areaStartTime != null) {
areaData.add(
listOf(
Area(areaStartTime, ItemStyle(currentColor ?: "#FF0000")),
Area(timeStr, ItemStyle(currentColor ?: "#FF0000"))
)
)
}
// 添加到 detailList,记录动作开始的时刻
detailList.add(
DetailItem(getFriendlyActionName(detail.a_action), time = timeStr)
)
// 更新为新的动作
lastAction = detail.a_action
areaStartTime = timeStr
currentColor = colors[detail.a_action] ?: "#FF0000" // 默认红色
}
} else {
// 如果当前动作为 "--",则结束当前区域,并重置状态
if (areaStartTime != null) {
// 添加到 detailList,记录动作开始的时刻
detailList.add(
DetailItem(getFriendlyActionName(detail.a_action), timeStr)
)
// 结束当前区域
areaData.add(
listOf(
Area(areaStartTime, ItemStyle(currentColor ?: "#FF0000")),
Area(timeStr, ItemStyle(currentColor ?: "#FF0000"))
)
)
// 重置状态
lastAction = null
areaStartTime = null
currentColor = null
}
}
}
// 处理最后一个区域的结束时间
if (areaStartTime != null && lastAction != null) {
areaData.add(
listOf(
Area(areaStartTime, ItemStyle(currentColor ?: "#FF0000")),
Area(yTotalData.last().first, ItemStyle(currentColor ?: "#FF0000"))
)
)
}
val analyticsData = VideoAnalyticsData(
yTotalData = yTotalData,
yMaskedData = yMaskedData,
areaData = areaData
)
// 返回数据
call.respond(
BaseResponse(
data = VideoAnalyticsDetail(
v_id = vId,
v_name = video[VideoTable.vName],
v_video_play_path = "http://${SERVER_PATH_OSS}:9000/video/" + video[VideoTable.vObjectName],
v_file_name = video[VideoTable.vFileName],
v_duration = video[VideoTable.vDuration],
v_size = video[VideoTable.vSize],
v_start_datetime = video[vStartDateTime]?.toString() ?: "",
v_video_codec = video[VideoTable.vVideoCodec],
v_audio_codec = video[VideoTable.vAudioCodec],
v_overall_bit_rate = video[VideoTable.vOverallBitRate],
v_resolution = video[VideoTable.vResolution],
v_a_time = video[VideoTable.vATime].toString(),
v_a_total_people = video[vATotalPeople], // 总人数
v_a_count_people = video[vACountPeople], // 佩戴口罩人数
v_a_max_stay_time = video[vAMaxStayTime], // 最大停留时间
v_a_max_action = getFriendlyActionName(video[vAMaxAction]), // 最大动作
v_a_average_masked_ratio = video[vAAverageMaskedRatio], // 平均佩戴口罩比例
v_a_details = analyticsData, // 这里是计算得来的 VideoAnalyticsData
v_details_list = detailList
)
)
)
}
}
}
}
}
}
}
suspend fun broadcastMessage(message: String) { // 封装的广播消息方法
println("发送消息:$message")
clients.forEach { client ->
try {
client.send(message) // 发送消息到每个客户端
} catch (e: Exception) {
println("发送消息给客户端时出错,移除客户端: ${e.message}")
clients.remove(client) // 移除已断开的客户端
}
}
}
private fun getFriendlyActionName(name: String): String {
return if (name == "feed") {
"喂桑"
} else if (name == "disinfection") {
"消毒"
} else {
name
}
}
@@ -1,4 +1,4 @@
package ink.snowflake.server.route.func package ink.snowflake.server.route
import ink.snowflake.server.model.response.* import ink.snowflake.server.model.response.*
import io.ktor.server.application.* import io.ktor.server.application.*
@@ -1,159 +0,0 @@
package ink.snowflake.server.route.func
import ink.snowflake.server.VIDEO_INPUT_PATH
import ink.snowflake.server.model.request.VideoAnalyticsRequest
import ink.snowflake.server.model.response.*
import ink.snowflake.server.repository.VideoDataBase
import ink.snowflake.server.utils.WebSocketManager.broadcastMessage
import ink.snowflake.server.utils.runCommand
import io.ktor.http.content.*
import io.ktor.server.application.*
import io.ktor.server.auth.*
import io.ktor.server.routing.*
import io.ktor.server.request.*
import io.ktor.server.response.*
import io.ktor.server.websocket.*
import io.ktor.websocket.*
import kotlinx.coroutines.channels.consumeEach
import java.io.File
import java.io.IOException
import java.util.*
val clients = Collections.synchronizedList<WebSocketServerSession>(ArrayList()) // 线程安全的客户端列表
var aiState = "等待分析任务中"
fun Application.VideoAnalytics() {
val repository = VideoDataBase()
routing {
// 实时发送AI状态
webSocket("/handleState") { // WebSocket 路由
clients.add(this) // 添加当前连接的客户端
send(aiState) // 向客户端发送连接成功消息
try {
incoming.consumeEach { frame -> // 持续接收消息
when (frame) {
is Frame.Text -> {
aiState = frame.readText() // 更新状态
broadcastMessage(aiState) // 使用封装的方法广播消息
}
is Frame.Close -> {
println("Closed")
clients.remove(this)
close() // 确保关闭 WebSocket 连接
return@consumeEach
}
// 其他消息类型的处理
is Frame.Binary -> TODO() // 处理二进制消息
is Frame.Ping -> TODO() // 处理 Ping 消息
is Frame.Pong -> TODO() // 处理 Pong 消息
}
}
} catch (e: Exception) {
// 处理接收消息时的异常
close(CloseReason(CloseReason.Codes.NORMAL, "Client disconnected"))
e.printStackTrace()
} finally {
clients.remove(this) // 确保在连接关闭时移除客户端
}
}
route("/api") {
// 上传分析结果
post("/saveVideoAnalyticsData") {
val request = call.receive<VideoAnalyticsRequest>()
// todo 上传这里未做测试
call.respond(BaseResponse(data = repository.saveVideoAnalyticsData(request)))
}
authenticate {
post("/upload") {
val multipart = call.receiveMultipart() //1G
// 确保 uploads 目录存在
val uploadDir = File(VIDEO_INPUT_PATH)
if (!uploadDir.exists()) {
if (!uploadDir.mkdirs()) {
println("无法创建目录")
throw IOException("Failed to create upload directory.")
}
}
var fileName = ""
var name = ""
var datetime = ""
broadcastMessage("正在上传数据")
multipart.forEachPart { part ->
when (part) {
is PartData.FileItem -> {
fileName = part.originalFileName ?: "unknown"
val file = File("$VIDEO_INPUT_PATH$fileName") // 保存路径
//ktor3
// file.outputStream().use { outputStream ->
// val writableChannel = Channels.newChannel(outputStream)
// part.provider().copyTo(writableChannel) // 复制到 WritableByteChannel
// }
//ktor2
part.streamProvider().use { inputStream ->
file.outputStream().buffered().use { outputStream ->
inputStream.copyTo(outputStream)
}
}
}
is PartData.FormItem -> {
when (part.name) {
"name" -> name = part.value
"datetime" -> datetime = part.value
}
}
else -> part.dispose()
}
}
call.respond(BaseResponse(message = "上传成功", data = null))
broadcastMessage("上传完成,开始启动AI引擎")
val command = listOf(
"/usr/bin/python3",
"/home/xhcp/mine/IntelligentVideoAnalytics/AI_Project/DeepStream_Action_Recognition/core/final.py",
"$VIDEO_INPUT_PATH$fileName",
datetime,
name
)
println("-----------------" + command.joinToString(" "))
runCommand(command) {
println(it)
}
}
// 获取已分析视频列表
get("/getVideoList") {
val res = repository.getVideoList()
call.respond(BaseResponse(data = res))
}
// 获取某视频分析详情
get("/getAnalyticsDetailByVideoId") {
// 获取 vId 参数
val vIdParam = call.parameters["vId"]
val vId = vIdParam?.toIntOrNull() // 将 vId 转换为 Int,确保安全
if (vId == null) {
call.respond(BaseResponse(status = true, message = "Invalid vId", data = null))
return@get
}
val result = repository.getAnalyticsDetailByVideoId(vId)
if (result != null) {
call.respond(BaseResponse(data = result))
}
}
}
}
}
}
suspend fun broadcastMessage(message: String) { // 封装的广播消息方法
println("发送消息:$message")
clients.forEach { client ->
try {
client.send(message) // 发送消息到每个客户端
} catch (e: Exception) {
println("发送消息给客户端时出错,移除客户端: ${e.message}")
clients.remove(client) // 移除已断开的客户端
}
}
}
@@ -10,12 +10,14 @@ import kotlinx.coroutines.launch
import kotlinx.datetime.LocalDateTime import kotlinx.datetime.LocalDateTime
import kotlinx.datetime.format import kotlinx.datetime.format
import kotlinx.datetime.toJavaLocalDateTime import kotlinx.datetime.toJavaLocalDateTime
import org.jetbrains.exposed.v1.jdbc.transactions.experimental.newSuspendedTransaction
import java.io.BufferedReader import java.io.BufferedReader
import java.io.InputStreamReader import java.io.InputStreamReader
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.time.format.DateTimeFormatter import java.time.format.DateTimeFormatter
import java.util.* import java.util.*
fun getUserIdByToken(call: ApplicationCall) : Int?{ fun getUserIdByToken(call: ApplicationCall) : Int?{
// 通过token获取user_id // 通过token获取user_id
return call.principal<JWTPrincipal>()?.payload?.getClaim("user_id")?.asInt() return call.principal<JWTPrincipal>()?.payload?.getClaim("user_id")?.asInt()
@@ -1,19 +0,0 @@
package ink.snowflake.server.utils.database
import ink.snowflake.server.model.database.AIProfile
import ink.snowflake.server.model.database.AIProfilesTable
import ink.snowflake.server.model.database.toAIProfile
import org.jetbrains.exposed.sql.selectAll
import org.jetbrains.exposed.sql.transactions.transaction
object AIDao {
/**
* 获取所有AI
*/
fun getAllAIProfiles(): List<AIProfile> {
return transaction {
AIProfilesTable.selectAll().map { it.toAIProfile() }
}
}
}
+42
View File
@@ -0,0 +1,42 @@
# EXAMPLE USAGE:
#
# Refer for explanation to following link:
# https://lefthook.dev/configuration/
#
# pre-push:
# jobs:
# - name: packages audit
# tags:
# - frontend
# - security
# run: yarn audit
#
# - name: gems audit
# tags:
# - backend
# - security
# run: bundle audit
#
# pre-commit:
# parallel: true
# jobs:
# - run: yarn eslint {staged_files}
# glob: "*.{js,ts,jsx,tsx}"
#
# - name: rubocop
# glob: "*.rb"
# exclude:
# - config/application.rb
# - config/routes.rb
# run: bundle exec rubocop --force-exclusion {all_files}
#
# - name: govet
# files: git ls-files -m
# glob: "*.go"
# run: go vet {files}
#
# - script: "hello.js"
# runner: node
#
# - script: "hello.go"
# runner: go run
+9 -3
View File
@@ -28,13 +28,19 @@
``` ```
2. 将Image保存为文件 2. 将Image保存为文件
``` ```shell
docker save -o vue.tar ce-vue docker save -o vue.tar ce-vue
``` ```
3. 将保存好的Image`vue.tar`上传到服务器 3. 将保存好的Image`vue.tar`上传到服务器
4. 在服务器读取Image 4. 停掉之前的docker compose
5. 删除之前的Image
```shell
docker rmi ce-vue:latest
```
6. 在服务器读取Image
```shell ```shell
docker load -i vue.tar docker load -i vue.tar
``` ```
@@ -45,7 +51,7 @@
1. 将项目目录下的`server/docker-compose.yml`复制到服务器 1. 将项目目录下的`server/docker-compose.yml`复制到服务器
2. 运行Docker Compose 2. 在`/opt/fianl/vue-image/文件夹下`运行Docker Compose
```shell ```shell
docker compose up -d docker compose up -d
``` ```
+82
View File
@@ -0,0 +1,82 @@
services:
# vue:
# image: node:18
# container_name: ce_vue_dev
# working_dir: /app
# volumes:
# - ./vue:/app
# ports:
# - "8090:8090"
# command: sh -c "npm install && npm run dev -- --host"
# networks:
# - ce_network
postgres:
image: postgres:15-alpine
container_name: ce_postgres
environment:
POSTGRES_DB: ktor
POSTGRES_USER: postgres
POSTGRES_PASSWORD: 123456
volumes:
- postgres_data:/var/lib/postgresql/data
ports:
- "5432:5432"
networks:
- ce_network
restart: unless-stopped
redis:
image: redis:7.2.3-alpine
container_name: ce_redis
volumes:
- redis_data:/data
ports:
- "6379:6379"
networks:
- ce_network
restart: unless-stopped
minio:
image: minio/minio:RELEASE.2025-03-12T18-04-18Z
container_name: ce_minio
environment:
MINIO_ACCESS_KEY: minioadmin
MINIO_SECRET_KEY: minioadmin
volumes:
- minio_data:/data
ports:
- "9000:9000"
- "9001:9001"
command: server /data --console-address ":9001"
networks:
- ce_network
restart: unless-stopped
# ollama:
# image: ollama/ollama
# container_name: ce_ollama
# deploy:
# resources:
# reservations:
# devices:7
# - capabilities: [gpu] # 启用 GPU 支持
# volumes:
# - ollama_data:/root/.ollama # 持久化 ollama 数据
# ports:
# - "11434:11434" # 暴露 ollama 的端口
# networks:
# - ce_network
# restart: unless-stopped
# 定义数据卷
volumes:
postgres_data:
redis_data:
minio_data:
ollama_data: # 定义 ollama 的数据卷
# 定义网络
networks:
ce_network:
driver: bridge
+2 -2
View File
@@ -4,13 +4,13 @@ VITE_PORT=8090
VITE_BASE=/ VITE_BASE=/
# 接口地址 # 接口地址
VITE_GLOB_API_URL=/api VITE_GLOB_API_URL=http://localhost:8089/api
# 是否开启 Nitro Mock服务,true 为开启,false 为关闭 # 是否开启 Nitro Mock服务,true 为开启,false 为关闭
VITE_NITRO_MOCK=false VITE_NITRO_MOCK=false
# 是否打开 devtoolstrue 为打开,false 为关闭 # 是否打开 devtoolstrue 为打开,false 为关闭
VITE_DEVTOOLS=false VITE_DEVTOOLS=true
# 是否注入全局loading # 是否注入全局loading
VITE_INJECT_APP_LOADING=true VITE_INJECT_APP_LOADING=true
+1
View File
@@ -45,6 +45,7 @@
"dayjs": "catalog:", "dayjs": "catalog:",
"js-sha256": "^0.11.0", "js-sha256": "^0.11.0",
"pinia": "catalog:", "pinia": "catalog:",
"video.js": "^8.22.0",
"vue": "catalog:", "vue": "catalog:",
"vue-router": "catalog:" "vue-router": "catalog:"
} }
+9 -1
View File
@@ -1,3 +1,5 @@
import type { Recordable } from '@vben/types';
import { h } from 'vue'; import { h } from 'vue';
import { setupVbenVxeTable, useVbenVxeGrid } from '@vben/plugins/vxe-table'; import { setupVbenVxeTable, useVbenVxeGrid } from '@vben/plugins/vxe-table';
@@ -61,7 +63,13 @@ setupVbenVxeTable({
}, },
useVbenForm, useVbenForm,
}); });
export { useVbenVxeGrid }; export { useVbenVxeGrid };
export type OnActionClickParams<T = Recordable<any>> = {
code: string;
row: T;
};
export type OnActionClickFn<T = Recordable<any>> = (
params: OnActionClickParams<T>,
) => void;
export type * from '@vben/plugins/vxe-table'; export type * from '@vben/plugins/vxe-table';
+1
View File
@@ -1,4 +1,5 @@
export * from './auth'; export * from './auth';
export * from './iva';
export * from './menu'; export * from './menu';
export * from './remote'; export * from './remote';
export * from './user'; export * from './user';
+17
View File
@@ -0,0 +1,17 @@
import { requestClient } from '#/api/request';
/**
* 获取已分析的视频列表
*/
export async function refreshVideoList(name = '') {
return requestClient.get('/iva/getVideoList', { params: { name } });
}
/**
* 获取已分析的视频列表
*/
export async function refreshVideoDetail(vId = '') {
return requestClient.get('/iva/getAnalyticsDetailByVideoId', {
params: { vId },
});
}
+1 -1
View File
@@ -6,5 +6,5 @@ import { requestClient } from '#/api/request';
* 获取用户所有菜单 * 获取用户所有菜单
*/ */
export async function getAllMenusApi() { export async function getAllMenusApi() {
return requestClient.get<RouteRecordStringComponent[]>('/menu/all'); return requestClient.get<RouteRecordStringComponent[]>('/user/menus');
} }
+2 -2
View File
@@ -3,8 +3,8 @@ import { requestClient } from '#/api/request';
/** /**
* 获取在线设备列表数据 * 获取在线设备列表数据
*/ */
export async function refreshDeviceList() { export async function refreshDeviceList(name = '') {
return requestClient.get('/remote/refreshDeviceList'); return requestClient.get('/remote/refreshDeviceList', { params: { name } });
} }
/** /**
+54
View File
@@ -0,0 +1,54 @@
import { requestClient } from '#/api/request';
export namespace SystemDeptApi {
export interface SystemDept {
[key: string]: any;
children?: SystemDept[];
id: string;
name: string;
remark?: string;
status: 0 | 1;
}
}
/**
* 获取部门列表数据
*/
async function getDeptList() {
return requestClient.get<Array<SystemDeptApi.SystemDept>>(
'/system/dept/list',
);
}
/**
* 创建部门
* @param data 部门数据
*/
async function createDept(
data: Omit<SystemDeptApi.SystemDept, 'children' | 'id'>,
) {
return requestClient.post('/system/dept/add', data);
}
/**
* 更新部门
*
* @param id 部门 ID
* @param data 部门数据
*/
async function updateDept(
id: string,
data: Omit<SystemDeptApi.SystemDept, 'children' | 'id'>,
) {
return requestClient.put(`/system/dept/${id}`, data);
}
/**
* 删除部门
* @param id 部门 ID
*/
async function deleteDept(id: string) {
return requestClient.delete(`/system/dept/${id}`);
}
export { createDept, deleteDept, getDeptList, updateDept };
@@ -0,0 +1,3 @@
export * from './dept';
export * from './menu';
export * from './role';
+158
View File
@@ -0,0 +1,158 @@
import type { Recordable } from '@vben/types';
import { requestClient } from '#/api/request';
export namespace SystemMenuApi {
/** 徽标颜色集合 */
export const BadgeVariants = [
'default',
'destructive',
'primary',
'success',
'warning',
] as const;
/** 徽标类型集合 */
export const BadgeTypes = ['dot', 'normal'] as const;
/** 菜单类型集合 */
export const MenuTypes = [
'catalog',
'menu',
'embedded',
'link',
'button',
] as const;
/** 系统菜单 */
export interface SystemMenu {
[key: string]: any;
/** 后端权限标识 */
authCode: string;
/** 子级 */
children?: SystemMenu[];
/** 组件 */
component?: string;
/** 菜单ID */
id: string;
/** 菜单元数据 */
meta?: {
/** 激活时显示的图标 */
activeIcon?: string;
/** 作为路由时,需要激活的菜单的Path */
activePath?: string;
/** 固定在标签栏 */
affixTab?: boolean;
/** 在标签栏固定的顺序 */
affixTabOrder?: number;
/** 徽标内容(当徽标类型为normal时有效) */
badge?: string;
/** 徽标类型 */
badgeType?: (typeof BadgeTypes)[number];
/** 徽标颜色 */
badgeVariants?: (typeof BadgeVariants)[number];
/** 在菜单中隐藏下级 */
hideChildrenInMenu?: boolean;
/** 在面包屑中隐藏 */
hideInBreadcrumb?: boolean;
/** 在菜单中隐藏 */
hideInMenu?: boolean;
/** 在标签栏中隐藏 */
hideInTab?: boolean;
/** 菜单图标 */
icon?: string;
/** 内嵌Iframe的URL */
iframeSrc?: string;
/** 是否缓存页面 */
keepAlive?: boolean;
/** 外链页面的URL */
link?: string;
/** 同一个路由最大打开的标签数 */
maxNumOfOpenTab?: number;
/** 无需基础布局 */
noBasicLayout?: boolean;
/** 是否在新窗口打开 */
openInNewWindow?: boolean;
/** 菜单排序 */
order?: number;
/** 额外的路由参数 */
query?: Recordable<any>;
/** 菜单标题 */
title?: string;
};
/** 菜单名称 */
name: string;
/** 路由路径 */
path: string;
/** 父级ID */
pid: string;
/** 重定向 */
redirect?: string;
/** 菜单类型 */
type: (typeof MenuTypes)[number];
}
}
/**
* 获取菜单数据列表
*/
async function getMenuList() {
return requestClient.get<Array<SystemMenuApi.SystemMenu>>(
'/system/menu/list',
);
}
async function isMenuNameExists(
name: string,
id?: SystemMenuApi.SystemMenu['id'],
) {
return requestClient.get<boolean>('/system/menu/name-exists', {
params: { id, name },
});
}
async function isMenuPathExists(
path: string,
id?: SystemMenuApi.SystemMenu['id'],
) {
return requestClient.get<boolean>('/system/menu/path-exists', {
params: { id, path },
});
}
/**
* 创建菜单
* @param data 菜单数据
*/
async function createMenu(
data: Omit<SystemMenuApi.SystemMenu, 'children' | 'id'>,
) {
return requestClient.post('/system/menu', data);
}
/**
* 更新菜单
*
* @param id 菜单 ID
* @param data 菜单数据
*/
async function updateMenu(
id: string,
data: Omit<SystemMenuApi.SystemMenu, 'children' | 'id'>,
) {
return requestClient.put(`/system/menu/${id}`, data);
}
/**
* 删除菜单
* @param id 菜单 ID
*/
async function deleteMenu(id: string) {
return requestClient.delete(`/system/menu/${id}`);
}
export {
createMenu,
deleteMenu,
getMenuList,
isMenuNameExists,
isMenuPathExists,
updateMenu,
};
+55
View File
@@ -0,0 +1,55 @@
import type { Recordable } from '@vben/types';
import { requestClient } from '#/api/request';
export namespace SystemRoleApi {
export interface SystemRole {
[key: string]: any;
id: string;
name: string;
permissions: string[];
remark?: string;
status: 0 | 1;
}
}
/**
* 获取角色列表数据
*/
async function getRoleList(params: Recordable<any>) {
return requestClient.get<Array<SystemRoleApi.SystemRole>>(
'/system/role/list',
{ params },
);
}
/**
* 创建角色
* @param data 角色数据
*/
async function createRole(data: Omit<SystemRoleApi.SystemRole, 'id'>) {
return requestClient.post('/system/role', data);
}
/**
* 更新角色
*
* @param id 角色 ID
* @param data 角色数据
*/
async function updateRole(
id: string,
data: Omit<SystemRoleApi.SystemRole, 'id'>,
) {
return requestClient.put(`/system/role/${id}`, data);
}
/**
* 删除角色
* @param id 角色 ID
*/
async function deleteRole(id: string) {
return requestClient.delete(`/system/role/${id}`);
}
export { createRole, deleteRole, getRoleList, updateRole };
@@ -7,7 +7,7 @@
"forgetPassword": "忘记密码" "forgetPassword": "忘记密码"
}, },
"dashboard": { "dashboard": {
"title": "概览", "title": "常规",
"analytics": "分析页", "analytics": "分析页",
"workspace": "工作台" "workspace": "工作台"
} }
+4 -3
View File
@@ -9,9 +9,10 @@ export const overridesPreferences = defineOverridesPreferences({
// overrides // overrides
app: { app: {
name: import.meta.env.VITE_APP_TITLE, name: import.meta.env.VITE_APP_TITLE,
layout: 'header-sidebar-nav', layout: 'header-sidebar-nav', // 布局方式
defaultHomePath: '/workspace', defaultHomePath: '/workspace', // 默认首页路径
enablePreferences: false, enablePreferences: false, // 是否启用偏好设置
loginExpiredMode: 'modal', // 登录过期模式 弹窗登录
}, },
theme: { theme: {
mode: 'light', mode: 'light',
@@ -19,6 +19,7 @@ const routes: RouteRecordRaw[] = [
name: 'IVA', name: 'IVA',
path: '/ai/iva', path: '/ai/iva',
meta: { meta: {
authority: ['iva'],
icon: 'mdi:video', icon: 'mdi:video',
title: $t('ai.intelligence_video_analysis'), title: $t('ai.intelligence_video_analysis'),
}, },
@@ -28,7 +29,8 @@ const routes: RouteRecordRaw[] = [
name: 'YSA', name: 'YSA',
path: '/ai/ysa', path: '/ai/ysa',
meta: { meta: {
icon: 'mdi:home', authority: ['ysa'],
icon: 'mdi:account-key-outline',
title: $t('ai.young_silkworm_analysis'), title: $t('ai.young_silkworm_analysis'),
}, },
component: () => import('#/views/ai/ysa/index.vue'), component: () => import('#/views/ai/ysa/index.vue'),
@@ -12,16 +12,6 @@ const routes: RouteRecordRaw[] = [
name: 'Dashboard', name: 'Dashboard',
path: '/dashboard', path: '/dashboard',
children: [ children: [
// {
// name: 'Analytics',
// path: '/analytics',
// component: () => import('#/views/dashboard/analytics/index.vue'),
// meta: {
// affixTab: true,
// icon: 'lucide:area-chart',
// title: $t('page.dashboard.analytics'),
// },
// },
{ {
name: 'Workspace', name: 'Workspace',
path: '/workspace', path: '/workspace',
@@ -31,6 +21,16 @@ const routes: RouteRecordRaw[] = [
title: $t('page.dashboard.workspace'), title: $t('page.dashboard.workspace'),
}, },
}, },
{
name: 'Remote',
path: '/remote',
component: () => import('#/views/remote/index.vue'),
meta: {
authority: ['remote'],
icon: 'mdi:home',
title: $t('remote.remote'),
},
},
], ],
}, },
]; ];
@@ -1,29 +0,0 @@
import type { RouteRecordRaw } from 'vue-router';
import { $t } from '#/locales';
const routes: RouteRecordRaw[] = [
{
meta: {
icon: 'ic:baseline-view-in-ar',
keepAlive: true,
order: 1,
title: $t('remote.tools'),
},
name: 'Room',
path: '/room',
children: [
{
name: 'Remote',
path: '/remote',
component: () => import('#/views/remote/index.vue'),
meta: {
icon: 'mdi:home',
title: $t('remote.remote'),
},
},
],
},
];
export default routes;
+485 -2
View File
@@ -1,5 +1,488 @@
<script setup lang="ts">
import type Player from 'video.js/dist/types/player';
import type { AnalysisOverviewItem } from '@vben/common-ui';
import type { EchartsUIType } from '@vben/plugins/echarts';
import { nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue';
import { AnalysisOverview } from '@vben/common-ui';
import {
SvgBellIcon,
SvgCakeIcon,
SvgCardIcon,
SvgDownloadIcon,
} from '@vben/icons';
import { EchartsUI, useEcharts } from '@vben/plugins/echarts';
import { Button, message } from 'ant-design-vue';
import videojs from 'video.js';
import * as api from '#/api';
import 'video.js/dist/video-js.css';
const list = ref<any[]>([]);
const error = ref<null | string>(null);
const filterKeyword = ref('');
const activeTab = ref<'detail' | 'video'>('detail');
const selectedItem = ref<any>(null);
const detailList = ref<any[]>([]);
const videoEl = ref<HTMLVideoElement | null>(null);
const player = ref<null | Player>(null);
async function loadList() {
error.value = null;
const res = await api.refreshVideoList(filterKeyword.value);
list.value = res || [];
}
function refreshList() {
filterKeyword.value = '';
loadList();
message.success('视频列表加载完成');
}
const createTask = () => {};
async function selectItem(item: any) {
const res = await api.refreshVideoDetail(item.v_id);
selectedItem.value = res;
refreshLineChart();
}
const tabs = [
{ key: 'detail', label: '分析详情' },
{ key: 'video', label: '分析视频' },
];
let overviewItems: AnalysisOverviewItem[];
// 监听关键词变化,调用防抖接口
watch(filterKeyword, () => {
loadList();
});
watch(selectedItem, () => {
overviewItems = [
{
icon: SvgCardIcon,
title: '动作',
totalTitle: '出现最多的动作',
totalValue: 0,
value: selectedItem.value.v_a_max_action,
},
{
icon: SvgCakeIcon,
title: '人数',
totalTitle: '最多同框人数',
totalValue: 0,
value: selectedItem.value.v_a_total_people,
},
{
icon: SvgDownloadIcon,
title: '人次',
totalTitle: '视频出现人次',
totalValue: 0,
value: selectedItem.value.v_a_count_people,
},
{
icon: SvgBellIcon,
title: '时间',
totalTitle: '最长停留时间',
totalValue: 0,
value: selectedItem.value.v_a_max_stay_time,
},
];
});
watch([activeTab, selectedItem], async ([tab]) => {
if (tab === 'video' && selectedItem.value?.v_video_play_path) {
refreshVideoPlayer();
}
});
onMounted(() => {
loadList();
});
onBeforeUnmount(() => {
player.value?.dispose();
player.value = null;
});
// ✅ 切换视频项时销毁并重建
function refreshVideoPlayer() {
nextTick(() => {
if (player.value) {
player.value.src([
{
src: selectedItem.value.v_video_play_path,
type: 'video/mp4',
},
]);
} else {
if (!videoEl.value) return;
player.value = videojs(videoEl.value, {
controls: true,
autoplay: false,
preload: 'auto',
sources: [
{
src: selectedItem.value.v_video_play_path,
type: 'video/mp4',
},
],
});
}
drawVideoProcess();
});
}
function drawVideoProcess() {
const data = selectedItem.value;
player.value?.one('loadedmetadata', () => {
// 计算起始时间戳
const vStartTime = new Date(data.v_start_datetime).getTime();
// 获取进度条 DOM
const progressControl = player.value?.controlBar?.progressControl?.el();
if (!progressControl) return;
// 清除旧的自定义进度段
progressControl
.querySelectorAll('.custom-range')
.forEach((el) => el.remove());
const duration = player.value?.duration() || 1;
const areaData = Array.isArray(data.v_a_details.areaData)
? data.v_a_details.areaData.map((actionGroup: any) => {
return actionGroup.map((action: any) => {
return { xAxis: action.xAxis, itemStyle: action.itemStyle };
});
})
: []; // 默认值为空数组
// 遍历区域数据,生成每个时间段
for (const area of areaData) {
const startMs = new Date(area[0].xAxis).getTime();
const endMs = new Date(area[1].xAxis).getTime();
const startSec = (startMs - vStartTime) / 1000;
const endSec = (endMs - vStartTime) / 1000;
if (startSec < 0 || endSec < 0 || startSec >= endSec) continue;
const startPct = (startSec / duration) * 100;
const endPct = (endSec / duration) * 100;
const rangeDiv = document.createElement('div');
rangeDiv.className = 'custom-range';
rangeDiv.style.position = 'absolute';
rangeDiv.style.left = `${startPct}%`;
rangeDiv.style.width = `${endPct - startPct}%`;
rangeDiv.style.height = '100%';
rangeDiv.style.backgroundColor = area[0].itemStyle.color;
rangeDiv.style.pointerEvents = 'none'; // 避免阻挡鼠标交互
rangeDiv.style.zIndex = '2';
progressControl.append(rangeDiv);
}
});
}
const showInfoStr = ref<Record<string, number | string>>({});
const chartRef1 = ref<EchartsUIType>();
const { renderEcharts: renderEcharts1 } = useEcharts(chartRef1);
const chartRef2 = ref<EchartsUIType>();
const { renderEcharts: renderEcharts2 } = useEcharts(chartRef2);
function refreshLineChart() {
const data = selectedItem.value;
showInfoStr.value = {
项目名: data.v_name,
视频开始时间: data.v_start_datetime,
文件名: data.v_file_name,
文件大小: `${data.v_size} MB`,
总时长: `${data.v_duration}`,
分辨率: data.v_resolution,
视频编码格式: data.v_video_codec,
音频编码格式: data.v_audio_codec,
总体比特率: data.v_overall_bit_rate,
};
detailList.value = data.v_details_list || [];
const detail = selectedItem.value.v_a_details;
let yTotalData = Array.isArray(detail.yTotalData)
? detail.yTotalData.map((item: any) => [item.first, item.second])
: []; // 默认值为空数组
let yMaskedData = Array.isArray(detail.yMaskedData)
? detail.yMaskedData.map((item: any) => [item.first, item.second])
: []; // 默认值为空数组
const areaData = Array.isArray(detail.areaData)
? detail.areaData.map((actionGroup: any) => {
return actionGroup.map((action: any) => {
return { xAxis: action.xAxis, itemStyle: action.itemStyle };
});
})
: []; // 默认值为空数组
yTotalData = yTotalData.map((item: any) => [
new Date(item[0]).getTime(),
item[1],
]);
yMaskedData = yMaskedData.map((item: any) => [
new Date(item[0]).getTime(),
item[1],
]);
renderEcharts1({
grid: { left: '3%', right: '4%', bottom: '3%', containLabel: true },
series: [
{
name: '总人数',
type: 'line',
step: 'end',
data: yTotalData,
markArea: {
itemStyle: { color: 'rgba(255, 173, 177, 0.4)' },
data: areaData,
},
},
{ name: '口罩佩戴人数', type: 'line', step: 'end', data: yMaskedData },
],
tooltip: {
axisPointer: {
lineStyle: {
color: '#019680',
width: 1,
},
},
trigger: 'axis',
},
xAxis: { type: 'time' },
yAxis: { type: 'value' },
});
const maskedRatio = data.v_a_average_masked_ratio * 100;
const noMaskedRatio = 100 - maskedRatio;
renderEcharts2({
legend: { top: '5%', left: 'center' },
series: [
{
animationDelay() {
return Math.random() * 100;
},
animationEasing: 'exponentialInOut',
animationType: 'scale',
avoidLabelOverlap: false,
color: ['#5ab1ef', '#b6a2de', '#67e0e3', '#2ec7c9'],
data: [
{
value: maskedRatio,
name: '未佩戴口罩(%)',
},
{
value: noMaskedRatio,
name: '佩戴口罩(%)',
},
],
emphasis: {
label: {
fontSize: '12',
fontWeight: 'bold',
show: true,
},
},
itemStyle: {
borderRadius: 10,
borderWidth: 3,
},
label: {
position: 'center',
show: false,
},
labelLine: {
show: false,
},
padAngle: 5,
name: '佩戴口罩人数占总人数的平均占比',
radius: ['40%', '70%'],
type: 'pie',
},
],
tooltip: {
trigger: 'item',
},
});
if (activeTab.value === 'video' && player.value) {
// 如果当前是视频标签页,刷新播放器
refreshVideoPlayer();
}
}
function onListItemClick(video: any) {
// 视频跳转到指定时间点
const vStartTime =
new Date(selectedItem.value.v_start_datetime).getTime() / 1000;
const xAxisTimeStart = new Date(video.time).getTime() / 1000;
const relativeTimeStart = xAxisTimeStart - vStartTime;
if (player.value) {
const duration = player.value.duration() || 1; // 获取视频总时长,避免除以0
if (relativeTimeStart >= 0 && relativeTimeStart <= duration) {
player.value.currentTime(relativeTimeStart);
} else {
message.warn(
`时间点超出视频范围,请选择 ${vStartTime} 秒到 秒之间的时间点`,
);
}
} else {
message.warn('请先选择左侧视频分析任务');
}
}
</script>
<template> <template>
<div> <div class="flex h-full w-full bg-gray-50">
<h1>正在开发中敬请期待</h1> <!-- 左侧筛选 + 列表 -->
<div class="flex w-64 flex-col border-r bg-white p-4">
<!-- 按钮组 -->
<div class="mb-4 flex justify-between space-x-2">
<Button type="primary" @click="createTask" class="flex-1">
新建任务
</Button>
<Button @click="refreshList" class="flex-1"> 刷新列表 </Button>
</div>
<!-- 筛选框 -->
<input
v-model="filterKeyword"
placeholder="筛选分析任务"
class="focus:ring-primary mb-4 rounded-md border px-3 py-2 focus:outline-none focus:ring-2"
/>
<!-- 列表 -->
<div class="flex-1 space-y-2 overflow-auto">
<div
v-for="item in list"
:key="item.v_id"
@click="selectItem(item)"
class="cursor-pointer rounded border p-3 hover:bg-gray-100"
:class="{ 'bg-gray-100': item.v_id === selectedItem?.v_id }"
>
<div class="text-base font-medium">{{ item.v_name }}</div>
<div class="text-sm text-gray-400">{{ item.v_a_time }}</div>
</div>
</div>
</div>
<!-- 右侧Tab 内容区 -->
<div class="flex flex-1 flex-col overflow-hidden p-6">
<!-- Tab 标题 -->
<div class="mb-4 flex shrink-0 space-x-4 border-b">
<button
v-for="tab in tabs"
:key="tab.key"
@click="activeTab = tab.key as 'detail' | 'video'"
class="px-4 py-2"
:class="[
activeTab === tab.key
? 'border-primary text-primary border-b-2'
: 'hover:text-primary text-gray-500',
]"
>
{{ tab.label }}
</button>
</div>
<!-- Tab 内容滚动区域 -->
<div class="flex-1 overflow-auto">
<div
v-if="!selectedItem"
class="flex h-full items-center justify-center text-gray-400"
>
请先选择左侧列表中的分析任务
</div>
<template v-else>
<div
v-show="activeTab === 'detail'"
class="flex h-full flex-col gap-4"
>
<!-- 主内容区域左右结构 -->
<div class="flex flex-1 gap-4">
<!-- 左侧 -->
<div class="flex w-72 flex-col gap-4">
<!-- 视频基础信息展示 -->
<div
class="mt-6 w-full rounded border bg-white p-4"
id="video_base_info"
>
<div
v-for="(value, key) in showInfoStr"
:key="key"
class="mb-2 flex text-sm text-gray-700"
>
<div class="w-32 font-medium text-gray-900">
{{ key }}
</div>
<div class="flex-1 break-all text-gray-600">
{{ value || '—' }}
</div>
</div>
</div>
<!-- 空白卡片 -->
<div class="h-[300px] flex-1 rounded border bg-white p-4">
<EchartsUI ref="chartRef2" />
</div>
</div>
<!-- 右侧 -->
<div class="flex flex-1 flex-col gap-4">
<!-- 四个统计卡片 -->
<AnalysisOverview
:items="overviewItems"
class="grid grid-cols-4 gap-4"
/>
<!-- 折线图区域 -->
<div class="flex-1 rounded border bg-white p-4">
<EchartsUI ref="chartRef1" />
</div>
</div>
</div>
</div>
<div v-show="activeTab === 'video'" class="flex h-full space-x-4">
<!-- 左侧视频区域 -->
<div class="flex-1 overflow-hidden rounded bg-black">
<video
ref="videoEl"
class="video-js vjs-default-skin h-full w-full"
preload="auto"
controls
></video>
</div>
<!-- 右侧时间点列表 -->
<div
class="flex w-1/4 flex-col overflow-auto rounded-md border bg-white"
>
<!-- 列表标题 -->
<div
class="flex justify-between border-b p-3 text-sm font-medium text-gray-700"
>
<span>事件</span>
<span>时间点</span>
</div>
<!-- 列表内容 -->
<div class="flex-1">
<div
v-for="(video, index) in detailList"
:key="index"
@click="onListItemClick(video)"
class="flex cursor-pointer justify-between border-b p-3 text-sm hover:bg-gray-100"
>
<span>{{ video.action }}</span>
<span>{{ video.time }}</span>
</div>
</div>
</div>
</div>
</template>
</div>
</div>
</div> </div>
</template> </template>
+29 -31
View File
@@ -1,4 +1,5 @@
<script lang="ts" setup> <script lang="ts" setup>
import type { VbenFormProps } from '#/adapter/form';
import type { VxeGridProps } from '#/adapter/vxe-table'; import type { VxeGridProps } from '#/adapter/vxe-table';
import { reactive } from 'vue'; import { reactive } from 'vue';
@@ -26,7 +27,6 @@ interface RowType {
deviceName: string; deviceName: string;
devicePort: number; devicePort: number;
} }
const gridOptions: VxeGridProps<RowType> = { const gridOptions: VxeGridProps<RowType> = {
columns: [ columns: [
{ title: '序号', type: 'seq', width: 50 }, { title: '序号', type: 'seq', width: 50 },
@@ -47,14 +47,14 @@ const gridOptions: VxeGridProps<RowType> = {
gt: 0, gt: 0,
}, },
// showOverflow: true, // 超出隐藏 // showOverflow: true, // 超出隐藏
height: '500px', height: '750px',
// keepSource: true, // keepSource: true,
stripe: true, // 条纹 stripe: true, // 条纹
proxyConfig: { proxyConfig: {
ajax: { ajax: {
query: async () => { query: async ({ page }, formValues) => {
return { return {
items: await refreshDeviceList(), items: await refreshDeviceList(formValues.deviceName),
}; };
}, },
}, },
@@ -68,33 +68,28 @@ const gridOptions: VxeGridProps<RowType> = {
}; };
// 筛选表单 // 筛选表单
// const formOptions: VbenFormProps = { const formOptions: VbenFormProps = {
// // 默认展开 // 默认展开
// collapsed: true, collapsed: true,
// schema: [ schema: [
// { {
// component: 'Input', component: 'Input',
// componentProps: { componentProps: {
// placeholder: '输入设备名', placeholder: '设备名',
// }, },
// fieldName: 'deviceName', fieldName: 'deviceName',
// label: '筛选', label: '筛选',
// width: 300, },
// }, ],
// ], // 控制表单是否显示折叠按钮
// // 控制表单是否显示折叠按钮 showCollapseButton: false,
// showCollapseButton: false, showDefaultActions: false, // 是否显示默认操作按钮
// submitButtonOptions: { submitOnChange: true, // 是否在字段值改变时提交表单
// content: '查询', submitOnEnter: true, // 按下回车时是否提交表单
// }, };
// submitOnChange: true, // 是否在字段值改变时提交表单
// submitOnEnter: true, // 按下回车时是否提交表单
// };
const [Grid] = useVbenVxeGrid({ const [Grid] = useVbenVxeGrid({
// formOptions, formOptions,
gridOptions, gridOptions,
separator: false, separator: false,
}); });
@@ -103,7 +98,7 @@ const [Grid] = useVbenVxeGrid({
* @param devicePort 设备端口号 * @param devicePort 设备端口号
*/ */
const handleConnect = async (devicePort: number) => { const handleConnect = async (devicePort: number) => {
message.info(await connectDeviceByPort(devicePort)); message.info(await connectDeviceByPort(devicePort.toString()));
}; };
const disconnectAll = async () => { const disconnectAll = async () => {
message.info(await disConnectAll()); message.info(await disConnectAll());
@@ -131,7 +126,10 @@ const disconnectAll = async () => {
</template> </template>
<template #default> <template #default>
<Card class="ml-2 h-full"> <Card class="ml-2 h-full">
<iframe src="http://171.212.101.199:8088" class="w-full"></iframe> <iframe
src="http://171.212.101.199:8088"
class="h-full w-full"
></iframe>
</Card> </Card>
</template> </template>
</ColPage> </ColPage>
File diff suppressed because one or more lines are too long
@@ -33,17 +33,22 @@ withDefaults(defineProps<Props>(), {
</CardHeader> </CardHeader>
<CardContent class="flex items-center justify-between"> <CardContent class="flex items-center justify-between">
<VbenCountToAnimator <div class="text-xl">
:end-val="item.value" <VbenCountToAnimator
:start-val="1" v-if="typeof item.value === 'number'"
class="text-xl" :end-val="item.value"
prefix="" :start-val="1"
/> prefix=""
/>
<span v-else>{{ item.value }}</span>
</div>
<VbenIcon :icon="item.icon" class="size-8 flex-shrink-0" /> <VbenIcon :icon="item.icon" class="size-8 flex-shrink-0" />
</CardContent> </CardContent>
<CardFooter class="justify-between"> <CardFooter class="justify-between">
<span>{{ item.totalTitle }}</span> <span>{{ item.totalTitle }}</span>
<VbenCountToAnimator <VbenCountToAnimator
v-if="item.totalValue !== 0"
:end-val="item.totalValue" :end-val="item.totalValue"
:start-val="1" :start-val="1"
prefix="" prefix=""
@@ -5,7 +5,7 @@ interface AnalysisOverviewItem {
title: string; title: string;
totalTitle: string; totalTitle: string;
totalValue: number; totalValue: number;
value: number; value: number | string;
} }
interface WorkbenchProjectItem { interface WorkbenchProjectItem {
@@ -5,8 +5,8 @@ interface Props {
} }
withDefaults(defineProps<Props>(), { withDefaults(defineProps<Props>(), {
height: '300px', height: '100%',
width: '100%', width: '98%',
}); });
</script> </script>
@@ -36,7 +36,6 @@ function useEcharts(chartRef: Ref<EchartsUIType>) {
if (!isDark.value) { if (!isDark.value) {
return {}; return {};
} }
return { return {
backgroundColor: 'transparent', backgroundColor: 'transparent',
}; };
+3617 -3931
View File
File diff suppressed because it is too large Load Diff