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 {
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"
}
@@ -80,11 +80,11 @@ dependencies {
implementation("org.jetbrains.exposed:exposed-dao:$exposed_version")
implementation("org.jetbrains.exposed:exposed-jdbc:$exposed_version")
implementation("org.jetbrains.exposed:exposed-json:$exposed_version")
implementation ("org.jetbrains.exposed:exposed-kotlin-datetime:${exposed_version}")
// MySQL JDBC
// implementation("mysql:mysql-connector-java:8.0.33")
// PostgreSQL JDBC
implementation("org.postgresql:postgresql:$postgres_version")
implementation("org.jetbrains.exposed:exposed-kotlin-datetime:$exposed_version")
implementation("ch.qos.logback:logback-classic:$logback_version")
// Radis
+1 -1
View File
@@ -3,6 +3,6 @@ ktor_version=3.0.2
kotlin_version=2.1.0
logback_version=1.4.14
kotlinx_html_version=0.10.1
exposed_version=0.57.0
exposed_version=1.0.0-beta-2
h2_version=2.1.214
postgres_version=42.7.4
@@ -2,13 +2,13 @@ package ink.snowflake.server
import com.google.gson.Gson
import ink.snowflake.server.plugins.*
import ink.snowflake.server.route.func.User
import ink.snowflake.server.route.func.chat
import ink.snowflake.server.route.configureSockets
import ink.snowflake.server.route.func.ImageAnalytics
import ink.snowflake.server.route.func.RemoteDebug
import ink.snowflake.server.route.func.VideoAnalytics
import ink.snowflake.server.route.func.VideoAnalyticsJetson
import ink.snowflake.server.route.User
import ink.snowflake.server.route.chat
import ink.snowflake.server.plugins.configureSockets
import ink.snowflake.server.route.ImageAnalytics
import ink.snowflake.server.route.RemoteDebug
import ink.snowflake.server.route.VideoAnalytics
import ink.snowflake.server.route.VideoAnalyticsJetson
import ink.snowflake.server.route.mainFunc
import ink.snowflake.server.utils.AppConfig
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 = "localhost"
const val SERVER_PATH_FRP = "171.212.101.201"
/**
* 服务器地址
* OSS 对象存储服务器地址
*/
const val SERVER_PATH_OSS = "171.212.101.199"
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 org.jetbrains.exposed.sql.SortOrder
import org.jetbrains.exposed.sql.insertAndGetId
import org.jetbrains.exposed.sql.kotlin.datetime.timestampLiteral
import org.jetbrains.exposed.sql.selectAll
import org.jetbrains.exposed.sql.transactions.transaction
import org.jetbrains.exposed.v1.core.SortOrder
import org.jetbrains.exposed.v1.datetime.timestampLiteral
import org.jetbrains.exposed.v1.jdbc.insertAndGetId
import org.jetbrains.exposed.v1.jdbc.selectAll
import org.jetbrains.exposed.v1.jdbc.transactions.transaction
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.model.database.ImageAnalyticsRequest
import ink.snowflake.server.model.database.ImageTable
import ink.snowflake.server.model.response.VideoListResponse
import ink.snowflake.server.SERVER_PATH_OSS
import ink.snowflake.server.model.request.ImageAnalyticsRequest
import ink.snowflake.server.database.table.ImageTable
import ink.snowflake.server.utils.formatLocalDateTimeToString
import kotlinx.datetime.toKotlinLocalDateTime
import org.jetbrains.exposed.sql.SortOrder
import org.jetbrains.exposed.sql.insert
import org.jetbrains.exposed.sql.selectAll
import org.jetbrains.exposed.sql.transactions.transaction
import org.jetbrains.exposed.v1.core.SortOrder
import org.jetbrains.exposed.v1.jdbc.insert
import org.jetbrains.exposed.v1.jdbc.selectAll
import org.jetbrains.exposed.v1.jdbc.transactions.transaction
import java.sql.Timestamp
object ImageDao {
@@ -41,7 +40,7 @@ object ImageDao {
.orderBy(ImageTable.upload_datetime, SortOrder.DESC)
.map {
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]),
file_name = it[ImageTable.file_name],
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.model.database.UserTable
import ink.snowflake.server.database.table.UserTable
import ink.snowflake.server.model.response.UserInfo
import ink.snowflake.server.utils.formatLocalDateTimeToString
import kotlinx.datetime.Clock
import org.jetbrains.exposed.sql.Op
import org.jetbrains.exposed.sql.SqlExpressionBuilder
import org.jetbrains.exposed.sql.SqlExpressionBuilder.eq
import org.jetbrains.exposed.sql.insertAndGetId
import org.jetbrains.exposed.sql.kotlin.datetime.timestampLiteral
import org.jetbrains.exposed.sql.select
import org.jetbrains.exposed.sql.selectAll
import org.jetbrains.exposed.sql.transactions.transaction
import org.jetbrains.exposed.v1.core.SqlExpressionBuilder.eq
import org.jetbrains.exposed.v1.datetime.timestampLiteral
import org.jetbrains.exposed.v1.jdbc.insertAndGetId
import org.jetbrains.exposed.v1.jdbc.selectAll
import org.jetbrains.exposed.v1.jdbc.transactions.transaction
object UserDAO {
/**
@@ -82,7 +78,8 @@ object UserDAO {
UserInfo(
username = it[UserTable.username],
email = it[UserTable.email],
phone = it[UserTable.phone]
phone = it[UserTable.phone],
roles = it[UserTable.roles]?.removePrefix("{")!!.removeSuffix("}").split(","),
)
}
.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.model.database.VideoTable
import ink.snowflake.server.model.database.VideoTable.vATime
import ink.snowflake.server.model.database.VideoTable.vName
import ink.snowflake.server.database.table.VideoAnalyticsDetailTable
import ink.snowflake.server.database.table.VideoTable
import ink.snowflake.server.database.table.VideoTable.vATime
import ink.snowflake.server.database.table.VideoTable.vName
import ink.snowflake.server.model.request.VideoAnalyticsRequest
import ink.snowflake.server.model.request.VideoDetail
import ink.snowflake.server.model.response.VideoListResponse
import ink.snowflake.server.utils.formatLocalDateTimeToString
import kotlinx.datetime.toKotlinLocalDateTime
import org.jetbrains.exposed.sql.ResultRow
import org.jetbrains.exposed.sql.SortOrder
import org.jetbrains.exposed.sql.insert
import org.jetbrains.exposed.sql.insertAndGetId
import org.jetbrains.exposed.sql.selectAll
import org.jetbrains.exposed.sql.transactions.transaction
import org.jetbrains.exposed.v1.core.ResultRow
import org.jetbrains.exposed.v1.core.SortOrder
import org.jetbrains.exposed.v1.jdbc.insert
import org.jetbrains.exposed.v1.jdbc.insertAndGetId
import org.jetbrains.exposed.v1.jdbc.selectAll
import org.jetbrains.exposed.v1.jdbc.transactions.transaction
import java.sql.Timestamp
object VideoDao {
@@ -54,27 +54,32 @@ object VideoDao {
}
}
fun getVideoList():List<VideoListResponse> {
fun getVideoList(name: String?): List<VideoListResponse> {
return transaction {
VideoTable.selectAll()
.orderBy(vATime, SortOrder.DESC)
val query = VideoTable.selectAll()
// 动态加 where 条件
if (!name.isNullOrBlank()) {
query.where { VideoTable.vName like "%${name}%" }
}
query.orderBy(VideoTable.vATime, SortOrder.DESC)
.map {
VideoListResponse(
v_id = it[VideoTable.id].value,
v_name = it[vName],
v_a_time = formatLocalDateTimeToString(it[vATime])
v_name = it[VideoTable.vName],
v_a_time = formatLocalDateTimeToString(it[VideoTable.vATime])
)
}
}
}
fun getAnalyticsDetailByVideoId(vId: Int): ResultRow? {
return transaction {
VideoTable.selectAll().where { VideoTable.id eq vId }.singleOrNull()
}
}
fun selectVideoDetailByVid(vId:Int): List<VideoDetail>{
fun selectVideoDetailByVid(vId: Int): List<VideoDetail> {
return transaction {
VideoAnalyticsDetailTable
.selectAll()
@@ -1,12 +1,12 @@
package ink.snowflake.server.model.database
package ink.snowflake.server.database.table
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 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 表
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.dao.id.IntIdTable
import org.jetbrains.exposed.sql.kotlin.datetime.timestamp
import org.jetbrains.exposed.sql.Column
import org.jetbrains.exposed.sql.ResultRow
import org.jetbrains.exposed.v1.core.Column
import org.jetbrains.exposed.v1.core.ResultRow
import org.jetbrains.exposed.v1.core.dao.id.IntIdTable
import org.jetbrains.exposed.v1.datetime.timestamp
object ChatRecordsTable : IntIdTable("chat_records") {
// 用户 ID(外键)
@@ -1,11 +1,10 @@
package ink.snowflake.server.model.database
package ink.snowflake.server.database.table
import com.google.gson.reflect.TypeToken
import ink.snowflake.server.gson
import org.jetbrains.exposed.dao.id.EntityID
import org.jetbrains.exposed.dao.id.IntIdTable
import org.jetbrains.exposed.sql.json.json
import org.jetbrains.exposed.sql.kotlin.datetime.datetime
import org.jetbrains.exposed.v1.core.dao.id.IntIdTable
import org.jetbrains.exposed.v1.datetime.datetime
import org.jetbrains.exposed.v1.json.json
object ImageTable : IntIdTable("image") {
val object_name = varchar("object_name", 255)
@@ -1,9 +1,7 @@
package ink.snowflake.server.model.database
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
package ink.snowflake.server.database.table
import org.jetbrains.exposed.v1.core.dao.id.IntIdTable
import org.jetbrains.exposed.v1.datetime.timestamp
object UserTable : IntIdTable("users") {
// 用户名
@@ -26,4 +24,7 @@ object UserTable : IntIdTable("users") {
// 更新时间
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") {
@@ -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.dao.id.IntIdTable
import org.jetbrains.exposed.sql.kotlin.datetime.datetime
import org.jetbrains.exposed.v1.core.dao.id.IntIdTable
import org.jetbrains.exposed.v1.datetime.datetime
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
@@ -16,6 +16,4 @@ data class ChatRequest(
@SerializedName("role")
val role: String = "user"
)
}
}
@@ -1,4 +1,4 @@
package ink.snowflake.server.model.database
package ink.snowflake.server.model.request
import kotlinx.serialization.Serializable
@@ -15,4 +15,4 @@ data class ImageAnalyticsRequest(
val average_confidence: Float, // 平均置信度
val other_info: Map<String, String>, // 额外信息
val processing_time: String // 处理时间
)
)
@@ -1,4 +1,4 @@
package ink.snowflake.server.model.ai
package ink.snowflake.server.model.response
import com.google.gson.annotations.SerializedName
@@ -32,6 +32,4 @@ data class ChatResponse(
@SerializedName("role")
val role: String = ""
)
}
}
@@ -4,6 +4,7 @@ import kotlinx.serialization.Serializable
@Serializable
data class VideoAnalyticsDetail(
val v_id: Int,
val v_name: String,
val v_video_play_path: String,
val v_file_name: String,
@@ -1,7 +1,7 @@
package ink.snowflake.server.plugins
import ink.snowflake.server.utils.AppConfig
import org.jetbrains.exposed.sql.*
import org.jetbrains.exposed.v1.jdbc.Database
fun configureDatabases(config: AppConfig) {
Database.connect(
@@ -6,8 +6,6 @@ import io.ktor.server.auth.*
import io.ktor.server.http.content.*
import io.ktor.server.plugins.autohead.AutoHeadResponse
import io.ktor.server.plugins.partialcontent.PartialContent
import io.ktor.server.routing.*
import org.jetbrains.exposed.sql.*
import java.io.File
fun Application.configureStaticPath() {
@@ -72,7 +72,8 @@ fun Application.configureStatusPages() {
status(HttpStatusCode.InternalServerError) { call, status ->
val response = BaseResponse<Nothing>(
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)
}
@@ -1,4 +1,4 @@
package ink.snowflake.server.route
package ink.snowflake.server.plugins
import io.ktor.serialization.gson.*
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 ink.snowflake.server.gson
import ink.snowflake.server.model.ai.ChatRequest
import ink.snowflake.server.model.ai.ChatResponse
import ink.snowflake.server.model.request.ChatRequest
import ink.snowflake.server.model.response.ChatResponse
import ink.snowflake.server.model.response.BaseResponse
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 io.ktor.client.*
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.VideoAnalyticsRequest
import ink.snowflake.server.model.request.ImageAnalyticsRequest
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.auth.*
import io.ktor.server.routing.*
import io.ktor.server.request.*
import io.ktor.server.response.*
import io.ktor.server.websocket.*
fun Application.ImageAnalytics() {
val repository = ImageDataBase()
routing {
route("/api") {
// 上传分析结果
post("/saveImageAnalyticsData") {
val request = call.receive<ImageAnalyticsRequest>()
call.respond(BaseResponse(data = repository.saveImageAnalyticsData(request)))
call.respond(BaseResponse(data = ImageDao.insertImageAnalyticsData(request)))
}
authenticate {
// 拍照保存为图片 并且调用Python程序进行分析
@@ -33,7 +30,7 @@ fun Application.ImageAnalytics() {
}
// 获取已分析视频列表
get("/getImageList") {
val res = repository.getImageList()
val res = ImageDao.getVideoList()
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 ink.snowflake.server.SERVER_PATH
import ink.snowflake.server.SERVER_PATH_FRP
import ink.snowflake.server.model.request.DevicesInfo
import ink.snowflake.server.model.response.*
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.statement.HttpResponse
import io.ktor.client.statement.bodyAsText
import io.ktor.http.content.*
import io.ktor.server.application.*
import io.ktor.server.auth.authenticate
import io.ktor.server.routing.*
import io.ktor.server.request.*
import io.ktor.server.response.*
import io.ktor.server.websocket.*
import io.ktor.utils.io.*
import io.ktor.websocket.*
import io.ktor.websocket.send
import kotlinx.coroutines.channels.consumeEach
@@ -59,7 +56,7 @@ fun Application.RemoteDebug() {
get("/connectLocalDevice") {
val port = call.parameters["port"]
if (port != null) {
val result = runAdbCommand("connect ${SERVER_PATH}:$port")
val result = runAdbCommand("connect ${SERVER_PATH_FRP}:$port")
call.respond(BaseResponse(data = result))
} else {
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.algorithms.Algorithm
import ink.snowflake.server.utils.database.AIDao
import ink.snowflake.server.model.request.CommonRequest
import ink.snowflake.server.model.request.LoginRequest
import ink.snowflake.server.model.request.RefreshTokenRequest
import ink.snowflake.server.model.request.RegisterRequest
import ink.snowflake.server.model.response.*
import ink.snowflake.server.repository.UserDataBase
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 io.ktor.server.application.*
import io.ktor.server.auth.*
@@ -22,12 +20,15 @@ import org.redisson.Redisson
import org.redisson.api.RBucket
import org.redisson.api.RedissonClient
import org.redisson.config.Config
import java.security.MessageDigest
import java.text.DateFormat
import java.time.Duration
import java.util.*
import javax.mail.*
import javax.mail.internet.InternetAddress
import javax.mail.internet.MimeMessage
import kotlin.collections.joinToString
import kotlin.text.Charsets.UTF_8
// 配置和初始化 Redis 客户端
fun setupRedis(): RedissonClient {
@@ -37,7 +38,6 @@ fun setupRedis(): RedissonClient {
}
fun Application.User(config: AppConfig) {
val repository = UserDataBase()
// 初始化 Redis 连接
val redisClient: RedissonClient = setupRedis()
routing {
@@ -47,21 +47,33 @@ fun Application.User(config: AppConfig) {
val loginRequest = call.receive<LoginRequest>()
val email = loginRequest.account
val password = loginRequest.password
val userId = repository.login(email, password)
call.respond(
if (userId == -1) {
BaseResponse(status = false, message = "尚未注册", data = null)
} else if (userId == -2) {
BaseResponse(status = false, message = "账号密码不匹配,请重新登录", data = null)
// 查找用户
val userId = UserDAO.getUserIdByEmail(email)
val res = if(userId == null){
// 账号不存在
-1
}else{
val userPassword = UserDAO.getPasswordById(userId)
// 验证密码
if (password == userPassword) {
// 登录成功
userId
} 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(
userId.toString(),
generateAccessToken(config, userId),
generateRefreshToken(config, userId)
generateAccessToken(config, res),
generateRefreshToken(config, res)
)
)
// match == -1
}
)
}
@@ -91,16 +103,24 @@ fun Application.User(config: AppConfig) {
} else if (storedCode != register.code) {
call.respond(BaseResponse(status = false, message = "验证码错误", data = null))
} 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(
BaseResponse(
status = true, message = "注册成功", data =
BaseResponse(
status = true, data = LoginResponse(
account,
generateAccessToken(config, userId),
generateRefreshToken(config, userId)
generateAccessToken(config, res),
generateRefreshToken(config, res)
)
)
)
@@ -253,4 +273,10 @@ fun sendVerificationEmail(config: AppConfig, recipientEmail: String, verificatio
e.printStackTrace()
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 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.format
import kotlinx.datetime.toJavaLocalDateTime
import org.jetbrains.exposed.v1.jdbc.transactions.experimental.newSuspendedTransaction
import java.io.BufferedReader
import java.io.InputStreamReader
import java.text.SimpleDateFormat
import java.time.format.DateTimeFormatter
import java.util.*
fun getUserIdByToken(call: ApplicationCall) : Int?{
// 通过token获取user_id
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保存为文件
```
```shell
docker save -o vue.tar ce-vue
```
3. 将保存好的Image`vue.tar`上传到服务器
4. 在服务器读取Image
4. 停掉之前的docker compose
5. 删除之前的Image
```shell
docker rmi ce-vue:latest
```
6. 在服务器读取Image
```shell
docker load -i vue.tar
```
@@ -45,7 +51,7 @@
1. 将项目目录下的`server/docker-compose.yml`复制到服务器
2. 运行Docker Compose
2. 在`/opt/fianl/vue-image/文件夹下`运行Docker Compose
```shell
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_GLOB_API_URL=/api
VITE_GLOB_API_URL=http://localhost:8089/api
# 是否开启 Nitro Mock服务,true 为开启,false 为关闭
VITE_NITRO_MOCK=false
# 是否打开 devtoolstrue 为打开,false 为关闭
VITE_DEVTOOLS=false
VITE_DEVTOOLS=true
# 是否注入全局loading
VITE_INJECT_APP_LOADING=true
+1
View File
@@ -45,6 +45,7 @@
"dayjs": "catalog:",
"js-sha256": "^0.11.0",
"pinia": "catalog:",
"video.js": "^8.22.0",
"vue": "catalog:",
"vue-router": "catalog:"
}
+9 -1
View File
@@ -1,3 +1,5 @@
import type { Recordable } from '@vben/types';
import { h } from 'vue';
import { setupVbenVxeTable, useVbenVxeGrid } from '@vben/plugins/vxe-table';
@@ -61,7 +63,13 @@ setupVbenVxeTable({
},
useVbenForm,
});
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';
+1
View File
@@ -1,4 +1,5 @@
export * from './auth';
export * from './iva';
export * from './menu';
export * from './remote';
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() {
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() {
return requestClient.get('/remote/refreshDeviceList');
export async function refreshDeviceList(name = '') {
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": "忘记密码"
},
"dashboard": {
"title": "概览",
"title": "常规",
"analytics": "分析页",
"workspace": "工作台"
}
+4 -3
View File
@@ -9,9 +9,10 @@ export const overridesPreferences = defineOverridesPreferences({
// overrides
app: {
name: import.meta.env.VITE_APP_TITLE,
layout: 'header-sidebar-nav',
defaultHomePath: '/workspace',
enablePreferences: false,
layout: 'header-sidebar-nav', // 布局方式
defaultHomePath: '/workspace', // 默认首页路径
enablePreferences: false, // 是否启用偏好设置
loginExpiredMode: 'modal', // 登录过期模式 弹窗登录
},
theme: {
mode: 'light',
@@ -19,6 +19,7 @@ const routes: RouteRecordRaw[] = [
name: 'IVA',
path: '/ai/iva',
meta: {
authority: ['iva'],
icon: 'mdi:video',
title: $t('ai.intelligence_video_analysis'),
},
@@ -28,7 +29,8 @@ const routes: RouteRecordRaw[] = [
name: 'YSA',
path: '/ai/ysa',
meta: {
icon: 'mdi:home',
authority: ['ysa'],
icon: 'mdi:account-key-outline',
title: $t('ai.young_silkworm_analysis'),
},
component: () => import('#/views/ai/ysa/index.vue'),
@@ -12,16 +12,6 @@ const routes: RouteRecordRaw[] = [
name: 'Dashboard',
path: '/dashboard',
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',
path: '/workspace',
@@ -31,6 +21,16 @@ const routes: RouteRecordRaw[] = [
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>
<div>
<h1>正在开发中敬请期待</h1>
<div class="flex h-full w-full bg-gray-50">
<!-- 左侧筛选 + 列表 -->
<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>
</template>
+29 -31
View File
@@ -1,4 +1,5 @@
<script lang="ts" setup>
import type { VbenFormProps } from '#/adapter/form';
import type { VxeGridProps } from '#/adapter/vxe-table';
import { reactive } from 'vue';
@@ -26,7 +27,6 @@ interface RowType {
deviceName: string;
devicePort: number;
}
const gridOptions: VxeGridProps<RowType> = {
columns: [
{ title: '序号', type: 'seq', width: 50 },
@@ -47,14 +47,14 @@ const gridOptions: VxeGridProps<RowType> = {
gt: 0,
},
// showOverflow: true, // 超出隐藏
height: '500px',
height: '750px',
// keepSource: true,
stripe: true, // 条纹
proxyConfig: {
ajax: {
query: async () => {
query: async ({ page }, formValues) => {
return {
items: await refreshDeviceList(),
items: await refreshDeviceList(formValues.deviceName),
};
},
},
@@ -68,33 +68,28 @@ const gridOptions: VxeGridProps<RowType> = {
};
// 筛选表单
// const formOptions: VbenFormProps = {
// // 默认展开
// collapsed: true,
// schema: [
// {
// component: 'Input',
// componentProps: {
// placeholder: '输入设备名',
// },
// fieldName: 'deviceName',
// label: '筛选',
// width: 300,
// },
// ],
// // 控制表单是否显示折叠按钮
// showCollapseButton: false,
// submitButtonOptions: {
// content: '查询',
// },
// submitOnChange: true, // 是否在字段值改变时提交表单
// submitOnEnter: true, // 按下回车时是否提交表单
// };
const formOptions: VbenFormProps = {
// 默认展开
collapsed: true,
schema: [
{
component: 'Input',
componentProps: {
placeholder: '设备名',
},
fieldName: 'deviceName',
label: '筛选',
},
],
// 控制表单是否显示折叠按钮
showCollapseButton: false,
showDefaultActions: false, // 是否显示默认操作按钮
submitOnChange: true, // 是否在字段值改变时提交表单
submitOnEnter: true, // 按下回车时是否提交表单
};
const [Grid] = useVbenVxeGrid({
// formOptions,
formOptions,
gridOptions,
separator: false,
});
@@ -103,7 +98,7 @@ const [Grid] = useVbenVxeGrid({
* @param devicePort 设备端口号
*/
const handleConnect = async (devicePort: number) => {
message.info(await connectDeviceByPort(devicePort));
message.info(await connectDeviceByPort(devicePort.toString()));
};
const disconnectAll = async () => {
message.info(await disConnectAll());
@@ -131,7 +126,10 @@ const disconnectAll = async () => {
</template>
<template #default>
<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>
</template>
</ColPage>
File diff suppressed because one or more lines are too long
@@ -33,17 +33,22 @@ withDefaults(defineProps<Props>(), {
</CardHeader>
<CardContent class="flex items-center justify-between">
<VbenCountToAnimator
:end-val="item.value"
:start-val="1"
class="text-xl"
prefix=""
/>
<div class="text-xl">
<VbenCountToAnimator
v-if="typeof item.value === 'number'"
:end-val="item.value"
:start-val="1"
prefix=""
/>
<span v-else>{{ item.value }}</span>
</div>
<VbenIcon :icon="item.icon" class="size-8 flex-shrink-0" />
</CardContent>
<CardFooter class="justify-between">
<span>{{ item.totalTitle }}</span>
<VbenCountToAnimator
v-if="item.totalValue !== 0"
:end-val="item.totalValue"
:start-val="1"
prefix=""
@@ -5,7 +5,7 @@ interface AnalysisOverviewItem {
title: string;
totalTitle: string;
totalValue: number;
value: number;
value: number | string;
}
interface WorkbenchProjectItem {
@@ -5,8 +5,8 @@ interface Props {
}
withDefaults(defineProps<Props>(), {
height: '300px',
width: '100%',
height: '100%',
width: '98%',
});
</script>
@@ -36,7 +36,6 @@ function useEcharts(chartRef: Ref<EchartsUIType>) {
if (!isDark.value) {
return {};
}
return {
backgroundColor: 'transparent',
};
+3617 -3931
View File
File diff suppressed because it is too large Load Diff