master
mula.liu 2025-04-03 02:23:49 +00:00
parent a9852fc9e6
commit 1c2e2975bb
29 changed files with 1217 additions and 1 deletions

7
.vscode/extensions.json vendored 100755
View File

@ -0,0 +1,7 @@
{
"recommendations": [
"pomdtr.excalidraw-editor",
"editorconfig.editorconfig",
"lokalise.i18n-ally"
]
}

View File

@ -1,3 +1,3 @@
# good-news
云桌面喜报管理系统
云桌面喜报管理系统,实现用户上传喜报照片,自动解析照片中的关键内容,比如项目名称、签单办事处、签单点数等。

1
entrypoint.sh 100755
View File

@ -0,0 +1 @@
./hello.sh

41
go.mod 100644
View File

@ -0,0 +1,41 @@
module goodnews
go 1.20
require (
github.com/gin-gonic/gin v1.9.1
github.com/google/uuid v1.3.0
github.com/otiai10/gosseract/v2 v2.4.1
gorm.io/driver/sqlite v1.5.2
gorm.io/gorm v1.25.2
)
require (
github.com/bytedance/sonic v1.9.1 // indirect
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 // indirect
github.com/gabriel-vasile/mimetype v1.4.2 // indirect
github.com/gin-contrib/sse v0.1.0 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.14.0 // indirect
github.com/goccy/go-json v0.10.2 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.2.4 // indirect
github.com/leodido/go-urn v1.2.4 // indirect
github.com/mattn/go-isatty v0.0.19 // indirect
github.com/mattn/go-sqlite3 v1.14.17 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/pelletier/go-toml/v2 v2.0.8 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.11 // indirect
golang.org/x/arch v0.3.0 // indirect
golang.org/x/crypto v0.9.0 // indirect
golang.org/x/net v0.10.0 // indirect
golang.org/x/sys v0.8.0 // indirect
golang.org/x/text v0.9.0 // indirect
google.golang.org/protobuf v1.30.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

100
go.sum 100644
View File

@ -0,0 +1,100 @@
github.com/bytedance/sonic v1.5.0/go.mod h1:ED5hyg4y6t3/9Ku1R6dU/4KyJ48DZ4jPhfY1O2AihPM=
github.com/bytedance/sonic v1.9.1 h1:6iJ6NqdoxCDr6mbY8h18oSO+cShGSMRGCEo7F2h0x8s=
github.com/bytedance/sonic v1.9.1/go.mod h1:i736AoUSYt75HyZLoJW9ERYxcy6eaN6h4BZXU064P/U=
github.com/chenzhuoyu/base64x v0.0.0-20211019084208-fb5309c8db06/go.mod h1:DH46F32mSOjUmXrMHnKwZdA8wcEefY7UVqBKYGjpdQY=
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 h1:qSGYFH7+jGhDF8vLC+iwCD4WpbV1EBDSzWkJODFLams=
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311/go.mod h1:b583jCggY9gE99b6G5LEC39OIiVsWj+R97kbl5odCEk=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/gabriel-vasile/mimetype v1.4.2 h1:w5qFW6JKBz9Y393Y4q372O9A7cUSequkh1Q7OhCmWKU=
github.com/gabriel-vasile/mimetype v1.4.2/go.mod h1:zApsH/mKG4w07erKIaJPFiX0Tsq9BFQgN3qGY5GnNgA=
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
github.com/gin-gonic/gin v1.9.1 h1:4idEAncQnU5cB7BeOkPtxjfCSye0AAm1R0RVIqJ+Jmg=
github.com/gin-gonic/gin v1.9.1/go.mod h1:hPrL7YrpYKXt5YId3A/Tnip5kqbEAP+KLuI3SUcPTeU=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.14.0 h1:vgvQWe3XCz3gIeFDm/HnTIbj6UGmg/+t63MyGU2n5js=
github.com/go-playground/validator/v10 v10.14.0/go.mod h1:9iXMNT7sEkjXb0I+enO7QXmzG6QCsPWY4zveKFVRSyU=
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/klauspost/cpuid/v2 v2.2.4 h1:acbojRNwl3o09bUq+yDCtZFc1aiwaAAxtcn8YkZXnvk=
github.com/klauspost/cpuid/v2 v2.2.4/go.mod h1:RVVoqg1df56z8g3pUjL/3lE5UfnlrJX8tyFgg4nqhuY=
github.com/leodido/go-urn v1.2.4 h1:XlAE/cm/ms7TE/VMVoduSpNBoyc2dOxHs5MZSwAN63Q=
github.com/leodido/go-urn v1.2.4/go.mod h1:7ZrI8mTSeBSHl/UaRyKQW1qZeMgak41ANeCNaVckg+4=
github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA=
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-sqlite3 v1.14.17 h1:mCRHCLDUBXgpKAqIKsaAaAsrAlbkeomtRFKXh2L6YIM=
github.com/mattn/go-sqlite3 v1.14.17/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/otiai10/gosseract/v2 v2.4.1 h1:G8AyBpXEeSlcq8TI85LH/pM5SXk8Djy2GEXisgyblRw=
github.com/otiai10/gosseract/v2 v2.4.1/go.mod h1:1gNWP4Hgr2o7yqWfs6r5bZxAatjOIdqWxJLWsTsembk=
github.com/otiai10/mint v1.6.3 h1:87qsV/aw1F5as1eH1zS/yqHY85ANKVMgkDrf9rcxbQs=
github.com/pelletier/go-toml/v2 v2.0.8 h1:0ctb6s9mE31h0/lhu+J6OPmVeDxJn+kYnJc2jZR9tGQ=
github.com/pelletier/go-toml/v2 v2.0.8/go.mod h1:vuYfssBdrU2XDZ9bYydBu6t+6a6PYNcZljzZR9VXg+4=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.3 h1:RP3t2pwF7cMEbC1dqtB6poj3niw/9gnV4Cjg5oW5gtY=
github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU=
github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
golang.org/x/arch v0.3.0 h1:02VY4/ZcO/gBOH6PUaoiptASxtXU10jazRCP865E97k=
golang.org/x/arch v0.3.0/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
golang.org/x/crypto v0.9.0 h1:LF6fAI+IutBocDJ2OT0Q1g8plpYljMZ4+lty+dsqw3g=
golang.org/x/crypto v0.9.0/go.mod h1:yrmDGqONDYtNj3tH8X9dzUun2m2lzPa9ngI6/RUPGR0=
golang.org/x/net v0.10.0 h1:X2//UzNDwYmtCLn7To6G58Wr6f5ahEAQgKNzv9Y951M=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/sys v0.0.0-20220704084225-05e143d24a9e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0 h1:EBmGv8NaZBZTWvrbjNoL6HVt+IVy3QDQpJs7VRIw3tU=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/text v0.9.0 h1:2sjJmO8cDvYveuX97RDLsxlyUxLl+GHoLxBiRdHllBE=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.30.0 h1:kPPoIgf3TsEvrm0PFe15JQ+570QVxYzEvvHqChK+cng=
google.golang.org/protobuf v1.30.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gorm.io/driver/sqlite v1.5.2 h1:TpQ+/dqCY4uCigCFyrfnrJnrW9zjpelWVoEVNy5qJkc=
gorm.io/driver/sqlite v1.5.2/go.mod h1:qxAuCol+2r6PannQDpOP1FP6ag3mKi4esLnB/jHed+4=
gorm.io/gorm v1.25.2 h1:gs1o6Vsa+oVKG/a9ElL3XgyGfghFfkKA2SInQaCyMho=
gorm.io/gorm v1.25.2/go.mod h1:L4uxeKpfBml98NYqVqwAdmV1a2nBtAec/cf3fpucW/k=
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=

BIN
goodnews.db 100644

Binary file not shown.

5
hello.sh 100755
View File

@ -0,0 +1,5 @@
#!/bin/bash
while :; do
{ echo -ne "HTTP/1.1 200 OK\r\nContent-Length: $(echo -n "Hello, World!")\r\n\r\nHello, World!"; } | nc -l -p 8080 -q 1
done

259
main.go 100644
View File

@ -0,0 +1,259 @@
package main
import (
"fmt"
"net/http"
"os"
"path/filepath"
"strings"
"github.com/gin-gonic/gin"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"github.com/google/uuid"
"goodnews/models"
"goodnews/services"
)
// 全局数据库连接
var db *gorm.DB
func main() {
// 初始化数据库
var err error
db, err = gorm.Open(sqlite.Open("goodnews.db"), &gorm.Config{})
if err != nil {
panic("failed to connect database")
}
// 自动迁移数据库
db.AutoMigrate(&models.GoodNews{})
// 初始化Gin路由
r := gin.Default()
// 设置静态文件服务
r.Static("/uploads", "./uploads")
// 设置页面路由
r.StaticFile("/", "./templates/index.html")
r.StaticFile("/manage", "./templates/manage.html")
// API路由组
api := r.Group("/api")
{
api.GET("/news", getGoodNewsList)
api.PUT("/news/:id", updateGoodNews)
api.DELETE("/news/:id", deleteGoodNews)
api.POST("/upload", handleUploadAndCreate)
api.GET("/offices", getOfficesList)
}
// 启动服务
r.Run(":8080")
}
// 获取喜报列表
func getGoodNewsList(c *gin.Context) {
var goodNewsList []models.GoodNews
result := db.Find(&goodNewsList)
if result.Error != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": result.Error.Error()})
return
}
// 处理每条记录添加图片URL并设置Office字段
for i := range goodNewsList {
// 将ImagePath转换为可访问的URL
goodNewsList[i].ImageURL = "/" + goodNewsList[i].ImagePath
// 确保Office字段与Representative字段一致
goodNewsList[i].Office = goodNewsList[i].Representative
}
c.JSON(http.StatusOK, goodNewsList)
}
// 获取代表处列表
func getOfficesList(c *gin.Context) {
var offices []string
result := db.Model(&models.GoodNews{}).Distinct().Pluck("representative", &offices)
if result.Error != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": result.Error.Error()})
return
}
c.JSON(http.StatusOK, offices)
}
// 处理文件上传并创建喜报
func handleUploadAndCreate(c *gin.Context) {
// 获取上传的图片
file, err := c.FormFile("file")
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "请选择要上传的图片文件"})
return
}
// 验证文件类型
ext := filepath.Ext(file.Filename)
allowedExts := map[string]bool{".jpg": true, ".jpeg": true, ".png": true}
if !allowedExts[strings.ToLower(ext)] {
c.JSON(http.StatusBadRequest, gin.H{"error": "只支持jpg、jpeg和png格式的图片"})
return
}
// 验证文件大小限制为10MB
if file.Size > 10<<20 { // 10 MB
c.JSON(http.StatusBadRequest, gin.H{"error": "图片大小不能超过10MB"})
return
}
// 生成唯一的文件名
newFileName := uuid.New().String() + ext
uploadDir := filepath.Join(".", "uploads")
filePath := filepath.Join(uploadDir, newFileName)
// 确保上传目录存在
if err := os.MkdirAll(uploadDir, 0755); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("创建上传目录失败: %v", err)})
return
}
// 保存图片
if err := c.SaveUploadedFile(file, filePath); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("图片保存失败: %v", err)})
return
}
// 创建OCR服务
ocrService, err := services.NewOCRService()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "OCR服务初始化失败"})
return
}
defer ocrService.Close()
// 提取图片信息
projectName, points, representative, err := ocrService.ExtractInfo(filePath)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("图片识别失败: %v", err)})
return
}
fmt.Println("OCR识别信息:", projectName, points, representative)
// 创建喜报记录
goodNews := models.GoodNews{
ProjectName: projectName,
Points: points,
Representative: representative,
ImagePath: filePath,
}
result := db.Create(&goodNews)
if result.Error != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": result.Error.Error()})
return
}
// 重新获取记录以确保包含ID
db.First(&goodNews, goodNews.ID)
goodNews.ImageURL = "/" + goodNews.ImagePath
c.JSON(http.StatusOK, goodNews)
}
// 更新喜报
func updateGoodNews(c *gin.Context) {
var goodNews models.GoodNews
if err := db.First(&goodNews, c.Param("id")).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "记录不存在"})
return
}
if err := c.ShouldBindJSON(&goodNews); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
db.Save(&goodNews)
c.JSON(http.StatusOK, goodNews)
}
// 删除喜报
func deleteGoodNews(c *gin.Context) {
var goodNews models.GoodNews
if err := db.First(&goodNews, c.Param("id")).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "记录不存在"})
return
}
db.Delete(&goodNews)
c.JSON(http.StatusOK, gin.H{"message": "删除成功"})
}
// 处理文件上传
func handleFileUpload(c *gin.Context) {
// 获取上传的文件
file, err := c.FormFile("file")
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "请选择要上传的文件"})
return
}
// 验证文件类型
ext := filepath.Ext(file.Filename)
allowedExts := map[string]bool{".jpg": true, ".jpeg": true, ".png": true}
if !allowedExts[strings.ToLower(ext)] {
c.JSON(http.StatusBadRequest, gin.H{"error": "只支持jpg、jpeg和png格式的图片"})
return
}
// 生成唯一的文件名
newFileName := uuid.New().String() + ext
uploadDir := filepath.Join(".", "uploads")
filePath := filepath.Join(uploadDir, newFileName)
// 确保上传目录存在
if err := os.MkdirAll(uploadDir, 0755); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("创建上传目录失败: %v", err)})
return
}
// 保存图片
if err := c.SaveUploadedFile(file, filePath); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("图片保存失败: %v", err)})
return
}
// 创建OCR服务
ocrService, err := services.NewOCRService()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "OCR服务初始化失败"})
return
}
defer ocrService.Close()
// 提取图片信息
projectName, points, representative, err := ocrService.ExtractInfo(filePath)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("图片识别失败: %v", err)})
return
}
// 创建喜报记录
goodNews := models.GoodNews{
ProjectName: projectName,
Points: points,
Representative: representative,
ImagePath: filePath,
}
result := db.Create(&goodNews)
if result.Error != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": result.Error.Error()})
return
}
// 重新获取记录以确保包含ID
db.First(&goodNews, goodNews.ID)
goodNews.ImageURL = "/" + goodNews.ImagePath
c.JSON(http.StatusOK, goodNews)
}

19
models/goodnews.go 100644
View File

@ -0,0 +1,19 @@
package models
import (
"time"
"gorm.io/gorm"
)
// GoodNews 喜报数据模型
type GoodNews struct {
gorm.Model
ID uint `json:"id" gorm:"primarykey"`
ProjectName string `json:"project_name"`
Points int `json:"points"`
Representative string `json:"representative"`
ImagePath string `json:"image_path"`
Office string `json:"office"`
UploadTime time.Time `json:"upload_time" gorm:"autoCreateTime"`
ImageURL string `json:"image_url" gorm:"-"`
}

15
project.md 100644
View File

@ -0,0 +1,15 @@
# 功能说明
## 页面规划
本项目有两个页面
+ 首页展示数据库中已有的喜报图片采用流式布局每行3个自动适配手机。
+ 瀑布流展示数据库中的喜报图片,默认按时间倒序展示,可以按月份、所属代表处进行过滤。
+ 瀑布流展示数据库中的喜报图片,点击图片后可查看原图。
+ 管理页面以列表形式展现数据库记录,数据库字段包括:id,项目名称,下单点数,所属代表处,上传时间,图片位置等。 提供修改和删除功能。
+ 管理页面上提供上传按钮,上传喜报图片,服务端解析图片后自动生成新一条的数据库记录。
# 技术栈
+ 本项目后端采用golang+gin
+ 前端采用element-ui
+ 数据库采用sqlite
+ 图片存储采用七牛云
+ 部署采用docker

View File

@ -0,0 +1,88 @@
package services
import (
"github.com/otiai10/gosseract/v2"
"regexp"
"strconv"
"strings"
)
// OCRService 提供OCR文字识别服务
type OCRService struct {
client *gosseract.Client
}
// NewOCRService 创建新的OCR服务实例
func NewOCRService() (*OCRService, error) {
client := gosseract.NewClient()
return &OCRService{client: client}, nil
}
// Close 关闭OCR服务
func (s *OCRService) Close() {
s.client.Close()
}
// ExtractInfo 从图片中提取喜报信息
func (s *OCRService) ExtractInfo(imagePath string) (string, int, string, error) {
// 设置中文语言包
err := s.client.SetLanguage("chi_sim")
if err != nil {
return "", 0, "", err
}
// 设置图片
err = s.client.SetImage(imagePath)
if err != nil {
return "", 0, "", err
}
// 获取文本
text, err := s.client.Text()
if err != nil {
return "", 0, "", err
}
// 提取项目名称
projectName := extractProjectName(text)
// 提取点数
points := extractPoints(text)
// 提取代表处
representative := extractRepresentative(text)
return projectName, points, representative, nil
}
// 提取项目名称
func extractProjectName(text string) string {
lines := strings.Split(text, "\n")
for _, line := range lines {
if strings.Contains(line, "项目") {
return strings.TrimSpace(line)
}
}
return ""
}
// 提取点数
func extractPoints(text string) int {
re := regexp.MustCompile(`(\d+)\s*点|points?`)
matches := re.FindStringSubmatch(text)
if len(matches) > 1 {
points, _ := strconv.Atoi(matches[1])
return points
}
return 0
}
// 提取代表处
func extractRepresentative(text string) string {
re := regexp.MustCompile(`([^\s]+代表处)`)
matches := re.FindStringSubmatch(text)
if len(matches) > 1 {
return matches[1]
}
return ""
}

View File

@ -0,0 +1,54 @@
package services
import (
"testing"
)
func TestOCRService_ExtractInfo(t *testing.T) {
// 创建OCR服务实例
ocr, err := NewOCRService()
if err != nil {
t.Fatalf("Failed to create OCR service: %v", err)
}
defer ocr.Close()
// 测试用例
tests := []struct {
name string
imagePath string
wantProject string
wantPoints int
wantRep string
wantErr bool
}{
{
name: "test_image",
imagePath: "../uploads/test_image.jpg",
// 预期结果将根据实际测试图片调整
wantProject: "乌省旗信创云桌面项目",
wantPoints: 360,
wantRep: "内蒙代表处",
wantErr: false,
},
}
// 运行测试用例
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
project, points, rep, err := ocr.ExtractInfo(tt.imagePath)
if (err != nil) != tt.wantErr {
t.Errorf("ExtractInfo() error = %v, wantErr %v", err, tt.wantErr)
return
}
if project != tt.wantProject {
t.Errorf("ExtractInfo() project = %v, want %v", project, tt.wantProject)
}
if points != tt.wantPoints {
t.Errorf("ExtractInfo() points = %v, want %v", points, tt.wantPoints)
}
if rep != tt.wantRep {
t.Errorf("ExtractInfo() representative = %v, want %v", rep, tt.wantRep)
}
})
}
}

View File

@ -0,0 +1,284 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>喜报管理系统</title>
<link rel="stylesheet" href="//unpkg.com/element-plus/dist/index.css">
<script src="//unpkg.com/vue@3"></script>
<script src="//unpkg.com/element-plus"></script>
<style>
.container {
max-width: 1200px;
margin: 0 auto;
padding: 20px;
}
.waterfall {
column-count: 3;
column-gap: 20px;
}
@media (max-width: 768px) {
.waterfall {
column-count: 2;
}
}
@media (max-width: 480px) {
.waterfall {
column-count: 1;
}
}
.waterfall-item {
break-inside: avoid;
margin-bottom: 20px;
}
.waterfall-item img {
width: 100%;
border-radius: 8px;
cursor: pointer;
transition: transform 0.3s;
}
.waterfall-item img:hover {
transform: scale(1.02);
}
.header {
margin-bottom: 20px;
}
.filter-section {
margin-bottom: 20px;
}
</style>
</head>
<body>
<div id="app">
<el-container>
<el-main>
<div class="container">
<div class="header">
<el-row :gutter="20" justify="space-between" align="middle">
<el-col :span="16">
<h1>喜报管理系统</h1>
</el-col>
<el-col :span="8" style="text-align: right;">
<el-button type="primary" @click="handleManageClick">管理喜报</el-button>
</el-col>
</el-row>
</div>
<div class="filter-section">
<el-row :gutter="20">
<el-col :span="8">
<el-date-picker
v-model="filterMonth"
type="month"
placeholder="选择月份"
format="YYYY年MM月"
value-format="YYYY-MM"
@change="handleFilterChange">
</el-date-picker>
</el-col>
<el-col :span="8">
<el-select v-model="filterOffice" placeholder="选择代表处" @change="handleFilterChange" clearable>
<el-option
v-for="office in offices"
:key="office"
:label="office"
:value="office">
</el-option>
</el-select>
</el-col>
<el-col :span="8" style="text-align: right;">
<el-button @click="resetFilters">重置筛选</el-button>
</el-col>
</el-row>
</div>
<div class="waterfall">
<div v-for="item in filteredNews" :key="item.id" class="waterfall-item">
<el-card>
<img :src="item.image_url" @click="showImageDetail(item)">
<div style="padding: 10px;">
<h3>{{ item.project_name }}</h3>
<p>签单点数: {{ item.points }}</p>
<p>代表处: {{ item.office }}</p>
<p>上传时间: {{ formatDate(item.upload_time) }}</p>
</div>
</el-card>
</div>
</div>
<!-- 图片详情对话框 -->
<el-dialog v-model="imageDialogVisible" width="80%">
<img :src="currentImage.image_url" style="width: 100%;">
<template #footer>
<el-button @click="imageDialogVisible = false">关闭</el-button>
<el-button type="primary" @click="editNews(currentImage)">编辑</el-button>
<el-button type="danger" @click="deleteNews(currentImage)">删除</el-button>
</template>
</el-dialog>
<!-- 编辑对话框 -->
<el-dialog v-model="editDialogVisible" title="编辑喜报信息" width="500px">
<el-form :model="editForm" label-width="100px">
<el-form-item label="项目名称">
<el-input v-model="editForm.project_name"></el-input>
</el-form-item>
<el-form-item label="签单点数">
<el-input-number v-model="editForm.points"></el-input-number>
</el-form-item>
<el-form-item label="代表处">
<el-select v-model="editForm.office">
<el-option
v-for="office in offices"
:key="office"
:label="office"
:value="office">
</el-option>
</el-select>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="editDialogVisible = false">取消</el-button>
<el-button type="primary" @click="submitEdit">确定</el-button>
</template>
</el-dialog>
</div>
</el-main>
</el-container>
</div>
<script>
const { createApp, ref, computed } = Vue
const app = createApp({
setup() {
const news = ref([])
const filterMonth = ref('')
const filterOffice = ref('')
const offices = ref([])
const imageDialogVisible = ref(false)
const editDialogVisible = ref(false)
const currentImage = ref({})
const editForm = ref({})
const filteredNews = computed(() => {
return news.value.filter(item => {
const monthMatch = !filterMonth.value || item.upload_time.startsWith(filterMonth.value)
const officeMatch = !filterOffice.value || item.office === filterOffice.value
return monthMatch && officeMatch
})
})
const handleManageClick = () => {
window.location.href = '/manage'
}
const fetchNews = async () => {
try {
const response = await fetch('/api/news')
const data = await response.json()
news.value = data
// 提取所有代表处
offices.value = [...new Set(data.map(item => item.office))]
} catch (error) {
ElMessage.error('获取数据失败')
}
}
const handleFilterChange = () => {
// 筛选会自动通过computed属性完成
}
const resetFilters = () => {
filterMonth.value = ''
filterOffice.value = ''
}
const showImageDetail = (item) => {
currentImage.value = item
imageDialogVisible.value = true
}
const editNews = (item) => {
editForm.value = { ...item }
imageDialogVisible.value = false
editDialogVisible.value = true
}
const submitEdit = async () => {
try {
const response = await fetch(`/api/news/${editForm.value.id}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(editForm.value)
})
if (response.ok) {
ElMessage.success('更新成功')
editDialogVisible.value = false
fetchNews()
} else {
throw new Error('更新失败')
}
} catch (error) {
ElMessage.error('更新失败')
}
}
const deleteNews = async (item) => {
try {
const response = await fetch(`/api/news/${item.id}`, {
method: 'DELETE'
})
if (response.ok) {
ElMessage.success('删除成功')
imageDialogVisible.value = false
fetchNews()
} else {
throw new Error('删除失败')
}
} catch (error) {
ElMessage.error('删除失败')
}
}
const formatDate = (dateString) => {
if (!dateString) return ''
const date = new Date(dateString)
return `${date.getFullYear()}年${date.getMonth() + 1}月${date.getDate()}日`
}
// 初始化时获取数据
fetchNews()
return {
news,
filterMonth,
filterOffice,
offices,
imageDialogVisible,
editDialogVisible,
currentImage,
editForm,
filteredNews,
handleManageClick,
handleFilterChange,
resetFilters,
showImageDetail,
editNews,
submitEdit,
deleteNews,
formatDate
}
}
})
app.use(ElementPlus)
app.mount('#app')
</script>
</body>
</html>

View File

@ -0,0 +1,343 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>喜报管理系统 - 管理页面</title>
<link rel="stylesheet" href="//unpkg.com/element-plus/dist/index.css">
<script src="//unpkg.com/vue@3"></script>
<script src="//unpkg.com/element-plus"></script>
<script src="//unpkg.com/@element-plus/icons-vue"></script>
<style>
.container {
max-width: 1200px;
margin: 0 auto;
padding: 20px;
}
.header {
margin-bottom: 30px;
border-bottom: 1px solid #eee;
padding-bottom: 20px;
}
.table-operations {
margin-right: 10px;
}
.el-table {
margin-bottom: 20px;
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
border-radius: 4px;
}
.el-pagination {
margin-top: 20px;
text-align: right;
}
.el-dialog__body {
padding: 30px 20px;
}
.upload-demo {
text-align: center;
}
</style>
</head>
<body>
<div id="app">
<el-container>
<el-main>
<div class="container">
<div class="header">
<el-row :gutter="20" justify="space-between" align="middle">
<el-col :span="16">
<h1>喜报管理系统 - 管理页面</h1>
</el-col>
<el-col :span="8" style="text-align: right;">
<el-button @click="goToIndex" style="margin-right: 10px">返回首页</el-button>
<el-button type="primary" @click="showUploadDialog">上传喜报</el-button>
</el-col>
</el-row>
</div>
<el-table :data="paginatedNews" style="width: 100%" v-loading="loading">
<el-table-column prop="id" label="ID" width="80"></el-table-column>
<el-table-column prop="project_name" label="项目名称" width="250"></el-table-column>
<el-table-column prop="points" label="签单点数" width="120"></el-table-column>
<el-table-column prop="office" label="代表处" width="150"></el-table-column>
<el-table-column prop="upload_time" label="上传时间" width="180">
<template #default="scope">
{{ formatDate(scope.row.upload_time) }}
</template>
</el-table-column>
<el-table-column label="操作" width="200">
<template #default="scope">
<el-button size="small" @click="showEditDialog(scope.row)">编辑</el-button>
<el-button size="small" type="danger" @click="handleDelete(scope.row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<el-pagination
v-model:current-page="currentPage"
v-model:page-size="pageSize"
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next, jumper"
:total="newsList.length"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
background
class="pagination"> </el-pagination>
<el-dialog v-model="uploadDialogVisible" title="上传喜报" width="500px">
<el-upload
class="upload-demo"
drag
action="/api/upload"
:on-success="handleUploadSuccess"
:on-error="handleUploadError">
<el-icon class="el-icon--upload"><upload-filled /></el-icon>
<div class="el-upload__text">拖拽文件到此处或 <em>点击上传</em></div>
</el-upload>
<template #footer>
<span class="dialog-footer">
<el-button @click="uploadDialogVisible = false">取消</el-button>
</span>
</template>
</el-dialog>
<!-- 编辑对话框 -->
<el-dialog v-model="editDialogVisible" title="编辑喜报信息" width="500px">
<el-form :model="editForm" label-width="100px">
<el-form-item label="项目名称">
<el-input v-model="editForm.project_name"></el-input>
</el-form-item>
<el-form-item label="签单点数">
<el-input-number v-model="editForm.points" :min="0"></el-input-number>
</el-form-item>
<el-form-item label="代表处">
<el-select v-model="editForm.office" placeholder="选择代表处">
<el-option
v-for="office in offices"
:key="office"
:label="office"
:value="office">
</el-option>
</el-select>
</el-form-item>
</el-form>
<template #footer>
<span class="dialog-footer">
<el-button @click="editDialogVisible = false">取消</el-button>
<el-button type="primary" @click="handleEdit">确定</el-button>
</span>
</template>
</el-dialog>
</div>
</el-main>
</el-container>
</div>
<script>
const { createApp, ref, computed } = Vue
const app = createApp({
setup() {
// 导入所需的图标组件
const { UploadFilled } = ElementPlusIconsVue
// 确保返回UploadFilled组件以供模板使用
const newsList = ref([])
const offices = ref([])
const uploadDialogVisible = ref(false)
const editDialogVisible = ref(false)
const loading = ref(false)
const currentPage = ref(1)
const pageSize = ref(10)
const editForm = ref({
id: null,
project_name: '',
points: 0,
office: ''
})
// 分页数据
const paginatedNews = computed(() => {
const start = (currentPage.value - 1) * pageSize.value
const end = start + pageSize.value
return newsList.value.slice(start, end)
})
// 处理每页显示数量变化
const handleSizeChange = (val) => {
pageSize.value = val
currentPage.value = 1
}
// 处理页码变化
const handleCurrentChange = (val) => {
currentPage.value = val
}
// 返回首页
const goToIndex = () => {
window.location.href = '/'
}
// 获取所有喜报数据
const fetchNews = async () => {
loading.value = true
try {
const response = await fetch('/api/news')
const data = await response.json()
newsList.value = data
} catch (error) {
console.error('获取数据失败:', error)
ElementPlus.ElMessage.error('获取数据失败')
} finally {
loading.value = false
}
}
// 获取代表处列表
const fetchOffices = async () => {
try {
const response = await fetch('/api/offices')
const data = await response.json()
offices.value = data
} catch (error) {
console.error('获取代表处列表失败:', error)
}
}
// 格式化日期
const formatDate = (dateStr) => {
const date = new Date(dateStr)
return date.toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
})
}
// 显示上传对话框
const showUploadDialog = () => {
uploadDialogVisible.value = true
}
// 显示编辑对话框
const showEditDialog = (row) => {
editForm.value = { ...row }
editDialogVisible.value = true
}
// 处理上传成功
const handleUploadSuccess = () => {
ElementPlus.ElMessage.success('上传成功')
uploadDialogVisible.value = false
fetchNews()
}
// 处理上传失败
const handleUploadError = () => {
ElementPlus.ElMessage.error('上传失败')
uploadDialogVisible.value = false
}
// 处理编辑
const handleEdit = async () => {
if (!editForm.value.id) {
ElementPlus.ElMessage.error('无效的记录ID')
return
}
try {
const response = await fetch(`/api/news/${editForm.value.id}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(editForm.value)
})
if (response.ok) {
ElementPlus.ElMessage.success('更新成功')
editDialogVisible.value = false
fetchNews()
} else {
ElementPlus.ElMessage.error('更新失败')
}
} catch (error) {
console.error('更新失败:', error)
ElementPlus.ElMessage.error('更新失败')
}
}
// 处理删除
const handleDelete = async (row) => {
if (!row || !row.id) {
ElementPlus.ElMessage.error('无效的记录ID')
return
}
try {
await ElementPlus.ElMessageBox.confirm(
'确定要删除这条记录吗?',
'警告',
{
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
}
)
const response = await fetch(`/api/news/${row.id}`, {
method: 'DELETE'
})
if (response.ok) {
ElementPlus.ElMessage.success('删除成功')
fetchNews()
} else {
ElementPlus.ElMessage.error('删除失败')
}
} catch (error) {
if (error !== 'cancel') {
console.error('删除失败:', error)
ElementPlus.ElMessage.error('删除失败')
}
}
}
// 初始化
fetchNews()
fetchOffices()
return {
newsList,
paginatedNews,
offices,
uploadDialogVisible,
editDialogVisible,
editForm,
loading,
currentPage,
pageSize,
formatDate,
showUploadDialog,
showEditDialog,
handleUploadSuccess,
handleUploadError,
handleEdit,
handleDelete,
handleSizeChange,
handleCurrentChange,
goToIndex,
UploadFilled
}
}
})
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
app.component(key, component)
}
app.use(ElementPlus)
app.mount('#app')
</script>
</body>
</html>

0
uploads/.gitkeep 100644
View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 548 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 551 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 551 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 164 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 559 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 559 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 164 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 548 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 548 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 551 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 164 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 548 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 559 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 559 KiB