feat: implement scenario-driven sales SOP platform
This commit is contained in:
11
.gitignore
vendored
11
.gitignore
vendored
@@ -1,3 +1,12 @@
|
|||||||
|
.DS_Store
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
codes/.env
|
||||||
|
codes/configs/config.local.yml
|
||||||
|
codes/web/node_modules/
|
||||||
|
codes/bin/
|
||||||
|
codes/coverage.out
|
||||||
|
|
||||||
# ---> Go
|
# ---> Go
|
||||||
# If you prefer the allow list template instead of the deny list, see community template:
|
# If you prefer the allow list template instead of the deny list, see community template:
|
||||||
# https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore
|
# https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore
|
||||||
@@ -117,7 +126,6 @@ out
|
|||||||
|
|
||||||
# Nuxt.js build / generate output
|
# Nuxt.js build / generate output
|
||||||
.nuxt
|
.nuxt
|
||||||
dist
|
|
||||||
|
|
||||||
# Gatsby files
|
# Gatsby files
|
||||||
.cache/
|
.cache/
|
||||||
@@ -173,4 +181,3 @@ docs/_book
|
|||||||
|
|
||||||
# TODO: where does this rule come from?
|
# TODO: where does this rule come from?
|
||||||
test/
|
test/
|
||||||
|
|
||||||
|
|||||||
20
codes/Makefile
Normal file
20
codes/Makefile
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
WEB_DIR := web
|
||||||
|
|
||||||
|
.PHONY: web-install web-build build test vet check
|
||||||
|
|
||||||
|
web-install:
|
||||||
|
cd $(WEB_DIR) && npm ci
|
||||||
|
|
||||||
|
web-build:
|
||||||
|
cd $(WEB_DIR) && npm run build
|
||||||
|
|
||||||
|
build: web-build
|
||||||
|
go build -o bin/iqudo-top1 .
|
||||||
|
|
||||||
|
test:
|
||||||
|
go test ./...
|
||||||
|
|
||||||
|
vet:
|
||||||
|
go vet ./...
|
||||||
|
|
||||||
|
check: test vet
|
||||||
26
codes/configs/config.example.yml
Normal file
26
codes/configs/config.example.yml
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
app:
|
||||||
|
name: iqudo-top1
|
||||||
|
env: development
|
||||||
|
server:
|
||||||
|
host: 127.0.0.1
|
||||||
|
port: 8080
|
||||||
|
read_timeout: 10s
|
||||||
|
write_timeout: 20s
|
||||||
|
shutdown_timeout: 10s
|
||||||
|
database:
|
||||||
|
host: 127.0.0.1
|
||||||
|
port: 3306
|
||||||
|
name: iqudo_top1
|
||||||
|
user: root
|
||||||
|
password: ""
|
||||||
|
max_idle_connections: 10
|
||||||
|
max_open_connections: 40
|
||||||
|
connection_max_lifetime: 30m
|
||||||
|
auth:
|
||||||
|
jwt_secret: ""
|
||||||
|
access_token_ttl: 2h
|
||||||
|
refresh_token_ttl: 168h
|
||||||
|
seed:
|
||||||
|
admin_username: admin
|
||||||
|
admin_password: ""
|
||||||
|
admin_display_name: 平台管理员
|
||||||
11
codes/configs/config.prod.yml
Normal file
11
codes/configs/config.prod.yml
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
app:
|
||||||
|
env: production
|
||||||
|
server:
|
||||||
|
host: 0.0.0.0
|
||||||
|
database:
|
||||||
|
max_idle_connections: 20
|
||||||
|
max_open_connections: 100
|
||||||
|
seed:
|
||||||
|
admin_username: ""
|
||||||
|
admin_password: ""
|
||||||
|
admin_display_name: ""
|
||||||
8
codes/configs/config.test.yml
Normal file
8
codes/configs/config.test.yml
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
app:
|
||||||
|
env: test
|
||||||
|
server:
|
||||||
|
port: 8081
|
||||||
|
database:
|
||||||
|
name: iqudo_top1_test
|
||||||
|
auth:
|
||||||
|
jwt_secret: test-only-secret
|
||||||
26
codes/configs/config.yml
Normal file
26
codes/configs/config.yml
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
app:
|
||||||
|
name: iqudo-top1
|
||||||
|
env: development
|
||||||
|
server:
|
||||||
|
host: 127.0.0.1
|
||||||
|
port: 8080
|
||||||
|
read_timeout: 10s
|
||||||
|
write_timeout: 20s
|
||||||
|
shutdown_timeout: 10s
|
||||||
|
database:
|
||||||
|
host: 127.0.0.1
|
||||||
|
port: 3306
|
||||||
|
name: iqudo_top1
|
||||||
|
user: root
|
||||||
|
password: root1234
|
||||||
|
max_idle_connections: 10
|
||||||
|
max_open_connections: 40
|
||||||
|
connection_max_lifetime: 30m
|
||||||
|
auth:
|
||||||
|
jwt_secret: local-development-secret-change-me
|
||||||
|
access_token_ttl: 2h
|
||||||
|
refresh_token_ttl: 168h
|
||||||
|
seed:
|
||||||
|
admin_username: admin
|
||||||
|
admin_password: admin123
|
||||||
|
admin_display_name: 平台管理员
|
||||||
55
codes/go.mod
Normal file
55
codes/go.mod
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
module git.iwork-ai.com/xdc/iqudo-top1
|
||||||
|
|
||||||
|
go 1.24
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/gin-gonic/gin v1.10.0
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.2.2
|
||||||
|
github.com/golang-migrate/migrate/v4 v4.18.2
|
||||||
|
github.com/google/uuid v1.6.0
|
||||||
|
github.com/ilyakaznacheev/cleanenv v1.5.0
|
||||||
|
go.uber.org/zap v1.27.0
|
||||||
|
golang.org/x/crypto v0.36.0
|
||||||
|
gorm.io/datatypes v1.2.7
|
||||||
|
gorm.io/driver/mysql v1.5.7
|
||||||
|
gorm.io/gorm v1.30.0
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
filippo.io/edwards25519 v1.1.0 // indirect
|
||||||
|
github.com/BurntSushi/toml v1.2.1 // indirect
|
||||||
|
github.com/bytedance/sonic v1.11.6 // indirect
|
||||||
|
github.com/bytedance/sonic/loader v0.1.1 // indirect
|
||||||
|
github.com/cloudwego/base64x v0.1.4 // indirect
|
||||||
|
github.com/cloudwego/iasm v0.2.0 // indirect
|
||||||
|
github.com/gabriel-vasile/mimetype v1.4.3 // 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.20.0 // indirect
|
||||||
|
github.com/go-sql-driver/mysql v1.8.1 // indirect
|
||||||
|
github.com/goccy/go-json v0.10.2 // indirect
|
||||||
|
github.com/hashicorp/errwrap v1.1.0 // indirect
|
||||||
|
github.com/hashicorp/go-multierror v1.1.1 // indirect
|
||||||
|
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||||
|
github.com/jinzhu/now v1.1.5 // indirect
|
||||||
|
github.com/joho/godotenv v1.5.1 // indirect
|
||||||
|
github.com/json-iterator/go v1.1.12 // indirect
|
||||||
|
github.com/klauspost/cpuid/v2 v2.2.7 // indirect
|
||||||
|
github.com/leodido/go-urn v1.4.0 // indirect
|
||||||
|
github.com/mattn/go-isatty v0.0.20 // 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.2.2 // indirect
|
||||||
|
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||||
|
github.com/ugorji/go/codec v1.2.12 // indirect
|
||||||
|
go.uber.org/atomic v1.7.0 // indirect
|
||||||
|
go.uber.org/multierr v1.10.0 // indirect
|
||||||
|
golang.org/x/arch v0.8.0 // indirect
|
||||||
|
golang.org/x/net v0.33.0 // indirect
|
||||||
|
golang.org/x/sys v0.31.0 // indirect
|
||||||
|
golang.org/x/text v0.23.0 // indirect
|
||||||
|
google.golang.org/protobuf v1.34.2 // indirect
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
|
olympos.io/encoding/edn v0.0.0-20201019073823-d3554ca0b0a3 // indirect
|
||||||
|
)
|
||||||
200
codes/go.sum
Normal file
200
codes/go.sum
Normal file
@@ -0,0 +1,200 @@
|
|||||||
|
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
|
||||||
|
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
|
||||||
|
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25UVaW/CKtUDjefjrs0SPonmDGUVOYP0=
|
||||||
|
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
|
||||||
|
github.com/BurntSushi/toml v1.2.1 h1:9F2/+DoOYIOksmaJFPw1tGFy1eDnIJXg+UHjuD8lTak=
|
||||||
|
github.com/BurntSushi/toml v1.2.1/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ=
|
||||||
|
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
|
||||||
|
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
|
||||||
|
github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0=
|
||||||
|
github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4=
|
||||||
|
github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM=
|
||||||
|
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
|
||||||
|
github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y=
|
||||||
|
github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
|
||||||
|
github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg=
|
||||||
|
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
|
||||||
|
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/dhui/dktest v0.4.4 h1:+I4s6JRE1yGuqflzwqG+aIaMdgXIorCf5P98JnaAWa8=
|
||||||
|
github.com/dhui/dktest v0.4.4/go.mod h1:4+22R4lgsdAXrDyaH4Nqx2JEz2hLp49MqQmm9HLCQhM=
|
||||||
|
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
|
||||||
|
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
|
||||||
|
github.com/docker/docker v27.2.0+incompatible h1:Rk9nIVdfH3+Vz4cyI/uhbINhEZ/oLmc+CBXmH6fbNk4=
|
||||||
|
github.com/docker/docker v27.2.0+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
|
||||||
|
github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c=
|
||||||
|
github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc=
|
||||||
|
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
|
||||||
|
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
|
||||||
|
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
|
||||||
|
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
||||||
|
github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0=
|
||||||
|
github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk=
|
||||||
|
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.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU=
|
||||||
|
github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
|
||||||
|
github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
|
||||||
|
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||||
|
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||||
|
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||||
|
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||||
|
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||||
|
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.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBExVwjEviJTixqxL8=
|
||||||
|
github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
|
||||||
|
github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI=
|
||||||
|
github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
|
||||||
|
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
|
||||||
|
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/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
|
||||||
|
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8=
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||||
|
github.com/golang-migrate/migrate/v4 v4.18.2 h1:2VSCMz7x7mjyTXx3m2zPokOY82LTRgxK1yQYKo6wWQ8=
|
||||||
|
github.com/golang-migrate/migrate/v4 v4.18.2/go.mod h1:2CM6tJvn2kqPXwnXO/d3rAQYiyoIm180VsO8PRX6Rpk=
|
||||||
|
github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 h1:au07oEsX2xN0ktxqI+Sida1w446QrXBRJ0nee3SNZlA=
|
||||||
|
github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0=
|
||||||
|
github.com/golang-sql/sqlexp v0.1.0 h1:ZCD6MBpcuOVfGVqsEmY5/4FtYiKz6tSyUv9LPEDei6A=
|
||||||
|
github.com/golang-sql/sqlexp v0.1.0/go.mod h1:J4ad9Vo8ZCWQ2GMrC4UCQy1JpCbwU9m3EOqtpKwwwHI=
|
||||||
|
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.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
|
github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
|
||||||
|
github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I=
|
||||||
|
github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
|
||||||
|
github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo=
|
||||||
|
github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM=
|
||||||
|
github.com/ilyakaznacheev/cleanenv v1.5.0 h1:0VNZXggJE2OYdXE87bfSSwGxeiGt9moSR2lOrsHHvr4=
|
||||||
|
github.com/ilyakaznacheev/cleanenv v1.5.0/go.mod h1:a5aDzaJrLCQZsazHol1w8InnDcOX0OColm64SlIi6gk=
|
||||||
|
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||||
|
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||||
|
github.com/jackc/pgservicefile v0.0.0-20231201235250-de7065d80cb9 h1:L0QtFUgDarD7Fpv9jeVMgy/+Ec0mtnmYuImjTz6dtDA=
|
||||||
|
github.com/jackc/pgservicefile v0.0.0-20231201235250-de7065d80cb9/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||||
|
github.com/jackc/pgx/v5 v5.5.5 h1:amBjrZVmksIdNjxGW/IiIMzxMKZFelXbUoPNb+8sjQw=
|
||||||
|
github.com/jackc/pgx/v5 v5.5.5/go.mod h1:ez9gk+OAat140fv9ErkZDYFWmXLfV+++K0uAOiwgm1A=
|
||||||
|
github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk=
|
||||||
|
github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||||
|
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/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
||||||
|
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
||||||
|
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.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM=
|
||||||
|
github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
|
||||||
|
github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
|
||||||
|
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||||
|
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||||
|
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
|
||||||
|
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
|
||||||
|
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
|
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
|
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
|
||||||
|
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||||
|
github.com/microsoft/go-mssqldb v1.7.2 h1:CHkFJiObW7ItKTJfHo1QX7QBBD1iV+mn1eOyRP3b/PA=
|
||||||
|
github.com/microsoft/go-mssqldb v1.7.2/go.mod h1:kOvZKUdrhhFQmxLZqbwUV0rHkNkZpthMITIb2Ko1IoA=
|
||||||
|
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
|
||||||
|
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
|
||||||
|
github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0=
|
||||||
|
github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y=
|
||||||
|
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/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
|
||||||
|
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
|
||||||
|
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
|
||||||
|
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
|
||||||
|
github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug=
|
||||||
|
github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM=
|
||||||
|
github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=
|
||||||
|
github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
|
||||||
|
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||||
|
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||||
|
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/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||||
|
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.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||||
|
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
|
||||||
|
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||||
|
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.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
|
||||||
|
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
|
||||||
|
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0 h1:TT4fX+nBOA/+LUkobKGW1ydGcn+G3vRw9+g5HwCphpk=
|
||||||
|
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0/go.mod h1:L7UH0GbB0p47T4Rri3uHjbpCFYrVrwc1I25QhNPiGK8=
|
||||||
|
go.opentelemetry.io/otel v1.29.0 h1:PdomN/Al4q/lN6iBJEN3AwPvUiHPMlt93c8bqTG5Llw=
|
||||||
|
go.opentelemetry.io/otel v1.29.0/go.mod h1:N/WtXPs1CNCUEx+Agz5uouwCba+i+bJGFicT8SR4NP8=
|
||||||
|
go.opentelemetry.io/otel/metric v1.29.0 h1:vPf/HFWTNkPu1aYeIsc98l4ktOQaL6LeSoeV2g+8YLc=
|
||||||
|
go.opentelemetry.io/otel/metric v1.29.0/go.mod h1:auu/QWieFVWx+DmQOUMgj0F8LHWdgalxXqvp7BII/W8=
|
||||||
|
go.opentelemetry.io/otel/trace v1.29.0 h1:J/8ZNK4XgR7a21DZUAsbF8pZ5Jcw1VhACmnYt39JTi4=
|
||||||
|
go.opentelemetry.io/otel/trace v1.29.0/go.mod h1:eHl3w0sp3paPkYstJOmAimxhiFXPg+MMTlEh3nsQgWQ=
|
||||||
|
go.uber.org/atomic v1.7.0 h1:ADUqmZGgLDDfbSL9ZmPxKTybcoEYHgpYfELNoN+7hsw=
|
||||||
|
go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
|
||||||
|
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||||
|
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||||
|
go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ=
|
||||||
|
go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
|
||||||
|
go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8=
|
||||||
|
go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
|
||||||
|
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
|
||||||
|
golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc=
|
||||||
|
golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
|
||||||
|
golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34=
|
||||||
|
golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc=
|
||||||
|
golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I=
|
||||||
|
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
|
||||||
|
golang.org/x/sync v0.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw=
|
||||||
|
golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
||||||
|
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik=
|
||||||
|
golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||||
|
golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY=
|
||||||
|
golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4=
|
||||||
|
golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 h1:+cNy6SZtPcJQH3LJVLOSmiC7MMxXNOb3PU/VUEz+EhU=
|
||||||
|
golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90=
|
||||||
|
google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg=
|
||||||
|
google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw=
|
||||||
|
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/datatypes v1.2.7 h1:ww9GAhF1aGXZY3EB3cJPJ7//JiuQo7DlQA7NNlVaTdk=
|
||||||
|
gorm.io/datatypes v1.2.7/go.mod h1:M2iO+6S3hhi4nAyYe444Pcb0dcIiOMJ7QHaUXxyiNZY=
|
||||||
|
gorm.io/driver/mysql v1.5.7 h1:MndhOPYOfEp2rHKgkZIhJ16eVUIRf2HmzgoPmh7FCWo=
|
||||||
|
gorm.io/driver/mysql v1.5.7/go.mod h1:sEtPWMiqiN1N1cMXoXmBbd8C6/l+TESwriotuRRpkDM=
|
||||||
|
gorm.io/driver/postgres v1.5.0 h1:u2FXTy14l45qc3UeCJ7QaAXZmZfDDv0YrthvmRq1l0U=
|
||||||
|
gorm.io/driver/postgres v1.5.0/go.mod h1:FUZXzO+5Uqg5zzwzv4KK49R8lvGIyscBOqYrtI1Ce9A=
|
||||||
|
gorm.io/driver/sqlite v1.4.3 h1:HBBcZSDnWi5BW3B3rwvVTc510KGkBkexlOg0QrmLUuU=
|
||||||
|
gorm.io/driver/sqlite v1.4.3/go.mod h1:0Aq3iPO+v9ZKbcdiz8gLWRw5VOPcBOPUQJFLq5e2ecI=
|
||||||
|
gorm.io/driver/sqlserver v1.6.0 h1:VZOBQVsVhkHU/NzNhRJKoANt5pZGQAS1Bwc6m6dgfnc=
|
||||||
|
gorm.io/driver/sqlserver v1.6.0/go.mod h1:WQzt4IJo/WHKnckU9jXBLMJIVNMVeTu25dnOzehntWw=
|
||||||
|
gorm.io/gorm v1.25.7/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8=
|
||||||
|
gorm.io/gorm v1.30.0 h1:qbT5aPv1UH8gI99OsRlvDToLxW5zR7FzS9acZDOZcgs=
|
||||||
|
gorm.io/gorm v1.30.0/go.mod h1:8Z33v652h4//uMA76KjeDH8mJXPm1QNCYrMeatR0DOE=
|
||||||
|
nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=
|
||||||
|
olympos.io/encoding/edn v0.0.0-20201019073823-d3554ca0b0a3 h1:slmdOY3vp8a7KQbHkL+FLbvbkgMqmXojpFUO/jENuqQ=
|
||||||
|
olympos.io/encoding/edn v0.0.0-20201019073823-d3554ca0b0a3/go.mod h1:oVgVk4OWVDi43qWBEyGhXgYxt7+ED4iYNpTngSLX2Iw=
|
||||||
|
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=
|
||||||
18
codes/internal/audit/audit.go
Normal file
18
codes/internal/audit/audit.go
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
package audit
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
|
||||||
|
"git.iwork-ai.com/xdc/iqudo-top1/internal/auth"
|
||||||
|
"git.iwork-ai.com/xdc/iqudo-top1/internal/model"
|
||||||
|
"gorm.io/datatypes"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
func Record(db *gorm.DB, principal auth.Principal, action, resource string, resourceID uint64, payload interface{}) error {
|
||||||
|
data, err := json.Marshal(payload)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return db.Create(&model.AuditLog{TenantID: principal.TenantID, UserID: principal.UserID, Action: action, Resource: resource, ResourceID: resourceID, Payload: datatypes.JSON(data)}).Error
|
||||||
|
}
|
||||||
80
codes/internal/auth/handler.go
Normal file
80
codes/internal/auth/handler.go
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"git.iwork-ai.com/xdc/iqudo-top1/internal/response"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Handler struct {
|
||||||
|
service *Service
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewHandler(service *Service) *Handler {
|
||||||
|
return &Handler{service: service}
|
||||||
|
}
|
||||||
|
|
||||||
|
type loginRequest struct {
|
||||||
|
Username string `json:"username" binding:"required,min=2,max=64"`
|
||||||
|
Password string `json:"password" binding:"required,min=6,max=128"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) Login(c *gin.Context) {
|
||||||
|
var input loginRequest
|
||||||
|
if err := c.ShouldBindJSON(&input); err != nil {
|
||||||
|
response.Error(c, http.StatusBadRequest, "INVALID_ARGUMENT", "请输入有效的用户名和密码")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
tokens, err := h.service.Login(input.Username, input.Password)
|
||||||
|
if errors.Is(err, ErrInvalidCredentials) {
|
||||||
|
response.Error(c, http.StatusUnauthorized, "INVALID_CREDENTIALS", "用户名或密码错误")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
response.Error(c, http.StatusInternalServerError, "LOGIN_FAILED", "登录失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.OK(c, tokens)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) Refresh(c *gin.Context) {
|
||||||
|
var input struct {
|
||||||
|
RefreshToken string `json:"refresh_token" binding:"required"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&input); err != nil {
|
||||||
|
response.Error(c, http.StatusBadRequest, "INVALID_ARGUMENT", "refresh_token 不能为空")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
tokens, err := h.service.Refresh(input.RefreshToken)
|
||||||
|
if err != nil {
|
||||||
|
response.Error(c, http.StatusUnauthorized, "INVALID_REFRESH_TOKEN", "登录状态已失效")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.OK(c, tokens)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) Me(c *gin.Context) {
|
||||||
|
principal, exists := PrincipalFromContext(c)
|
||||||
|
if !exists {
|
||||||
|
response.Error(c, http.StatusUnauthorized, "UNAUTHORIZED", "请先登录")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.OK(c, principal)
|
||||||
|
}
|
||||||
|
|
||||||
|
const principalContextKey = "principal"
|
||||||
|
|
||||||
|
func SetPrincipal(c *gin.Context, principal Principal) {
|
||||||
|
c.Set(principalContextKey, principal)
|
||||||
|
}
|
||||||
|
|
||||||
|
func PrincipalFromContext(c *gin.Context) (Principal, bool) {
|
||||||
|
value, exists := c.Get(principalContextKey)
|
||||||
|
if !exists {
|
||||||
|
return Principal{}, false
|
||||||
|
}
|
||||||
|
principal, ok := value.(Principal)
|
||||||
|
return principal, ok
|
||||||
|
}
|
||||||
185
codes/internal/auth/service.go
Normal file
185
codes/internal/auth/service.go
Normal file
@@ -0,0 +1,185 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.iwork-ai.com/xdc/iqudo-top1/internal/config"
|
||||||
|
"git.iwork-ai.com/xdc/iqudo-top1/internal/model"
|
||||||
|
"github.com/golang-jwt/jwt/v5"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
"gorm.io/datatypes"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
var ErrInvalidCredentials = errors.New("invalid username or password")
|
||||||
|
|
||||||
|
type Principal struct {
|
||||||
|
UserID uint64 `json:"user_id"`
|
||||||
|
TenantID uint64 `json:"tenant_id"`
|
||||||
|
RoleCode string `json:"role_code"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
DisplayName string `json:"display_name"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Claims struct {
|
||||||
|
TenantID uint64 `json:"tenant_id"`
|
||||||
|
RoleCode string `json:"role_code"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
DisplayName string `json:"display_name"`
|
||||||
|
jwt.RegisteredClaims
|
||||||
|
}
|
||||||
|
|
||||||
|
type TokenPair struct {
|
||||||
|
AccessToken string `json:"access_token"`
|
||||||
|
RefreshToken string `json:"refresh_token"`
|
||||||
|
ExpiresAt time.Time `json:"expires_at"`
|
||||||
|
User Principal `json:"user"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Service struct {
|
||||||
|
db *gorm.DB
|
||||||
|
cfg config.AuthConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewService(db *gorm.DB, cfg config.AuthConfig) *Service {
|
||||||
|
return &Service{db: db, cfg: cfg}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) Login(username, password string) (TokenPair, error) {
|
||||||
|
var user model.User
|
||||||
|
if err := s.db.Where("username = ? AND status = ?", username, "active").First(&user).Error; err != nil {
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return TokenPair{}, ErrInvalidCredentials
|
||||||
|
}
|
||||||
|
return TokenPair{}, err
|
||||||
|
}
|
||||||
|
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(password)); err != nil {
|
||||||
|
return TokenPair{}, ErrInvalidCredentials
|
||||||
|
}
|
||||||
|
principal, err := s.principalForUser(user)
|
||||||
|
if err != nil {
|
||||||
|
return TokenPair{}, err
|
||||||
|
}
|
||||||
|
return s.issueTokenPair(principal)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) Refresh(rawToken string) (TokenPair, error) {
|
||||||
|
hash := tokenHash(rawToken)
|
||||||
|
var stored model.RefreshToken
|
||||||
|
err := s.db.Where("token_hash = ? AND revoked_at IS NULL AND expires_at > ?", hash, time.Now()).First(&stored).Error
|
||||||
|
if err != nil {
|
||||||
|
return TokenPair{}, ErrInvalidCredentials
|
||||||
|
}
|
||||||
|
var user model.User
|
||||||
|
if err := s.db.First(&user, stored.UserID).Error; err != nil {
|
||||||
|
return TokenPair{}, err
|
||||||
|
}
|
||||||
|
principal, err := s.principalForUser(user)
|
||||||
|
if err != nil {
|
||||||
|
return TokenPair{}, err
|
||||||
|
}
|
||||||
|
now := time.Now()
|
||||||
|
if err := s.db.Model(&stored).Update("revoked_at", &now).Error; err != nil {
|
||||||
|
return TokenPair{}, err
|
||||||
|
}
|
||||||
|
return s.issueTokenPair(principal)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) ParseAccessToken(rawToken string) (Principal, error) {
|
||||||
|
claims := &Claims{}
|
||||||
|
token, err := jwt.ParseWithClaims(rawToken, claims, func(token *jwt.Token) (interface{}, error) {
|
||||||
|
if token.Method.Alg() != jwt.SigningMethodHS256.Alg() {
|
||||||
|
return nil, fmt.Errorf("unexpected signing method: %s", token.Method.Alg())
|
||||||
|
}
|
||||||
|
return []byte(s.cfg.JWTSecret), nil
|
||||||
|
})
|
||||||
|
if err != nil || !token.Valid {
|
||||||
|
return Principal{}, ErrInvalidCredentials
|
||||||
|
}
|
||||||
|
userID, err := parseUint(claims.Subject)
|
||||||
|
if err != nil {
|
||||||
|
return Principal{}, ErrInvalidCredentials
|
||||||
|
}
|
||||||
|
return Principal{UserID: userID, TenantID: claims.TenantID, RoleCode: claims.RoleCode, Username: claims.Username, DisplayName: claims.DisplayName}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) issueTokenPair(principal Principal) (TokenPair, error) {
|
||||||
|
now := time.Now()
|
||||||
|
expiresAt := now.Add(s.cfg.AccessTokenTTL)
|
||||||
|
claims := Claims{
|
||||||
|
TenantID: principal.TenantID, RoleCode: principal.RoleCode, Username: principal.Username, DisplayName: principal.DisplayName,
|
||||||
|
RegisteredClaims: jwt.RegisteredClaims{Subject: fmt.Sprintf("%d", principal.UserID), IssuedAt: jwt.NewNumericDate(now), ExpiresAt: jwt.NewNumericDate(expiresAt)},
|
||||||
|
}
|
||||||
|
access, err := jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString([]byte(s.cfg.JWTSecret))
|
||||||
|
if err != nil {
|
||||||
|
return TokenPair{}, err
|
||||||
|
}
|
||||||
|
refresh := uuid.NewString() + uuid.NewString()
|
||||||
|
record := model.RefreshToken{TenantID: principal.TenantID, UserID: principal.UserID, TokenHash: tokenHash(refresh), ExpiresAt: now.Add(s.cfg.RefreshTokenTTL)}
|
||||||
|
if err := s.db.Create(&record).Error; err != nil {
|
||||||
|
return TokenPair{}, err
|
||||||
|
}
|
||||||
|
return TokenPair{AccessToken: access, RefreshToken: refresh, ExpiresAt: expiresAt, User: principal}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) principalForUser(user model.User) (Principal, error) {
|
||||||
|
type row struct {
|
||||||
|
TenantID uint64
|
||||||
|
RoleCode string
|
||||||
|
}
|
||||||
|
var membership row
|
||||||
|
err := s.db.Table("tenant_members tm").Select("tm.tenant_id, r.code AS role_code").Joins("JOIN roles r ON r.id = tm.role_id").Where("tm.user_id = ? AND tm.status = ?", user.ID, "active").First(&membership).Error
|
||||||
|
if err != nil {
|
||||||
|
return Principal{}, err
|
||||||
|
}
|
||||||
|
return Principal{UserID: user.ID, TenantID: membership.TenantID, RoleCode: membership.RoleCode, Username: user.Username, DisplayName: user.DisplayName}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func Seed(db *gorm.DB, cfg config.SeedConfig) error {
|
||||||
|
if cfg.AdminUsername == "" || cfg.AdminPassword == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return db.Transaction(func(tx *gorm.DB) error {
|
||||||
|
var tenant model.Tenant
|
||||||
|
if err := tx.Where("slug = ?", "default").FirstOrCreate(&tenant, model.Tenant{Name: "默认企业", Slug: "default", Status: "active"}).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
permissions := datatypes.JSON([]byte(`["*"]`))
|
||||||
|
var role model.Role
|
||||||
|
if err := tx.Where("tenant_id = ? AND code = ?", tenant.ID, "admin").FirstOrCreate(&role, model.Role{TenantID: tenant.ID, Name: "管理员", Code: "admin", Permissions: permissions}).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var user model.User
|
||||||
|
err := tx.Where("username = ?", cfg.AdminUsername).First(&user).Error
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
hash, hashErr := bcrypt.GenerateFromPassword([]byte(cfg.AdminPassword), bcrypt.DefaultCost)
|
||||||
|
if hashErr != nil {
|
||||||
|
return hashErr
|
||||||
|
}
|
||||||
|
user = model.User{Username: cfg.AdminUsername, PasswordHash: string(hash), DisplayName: cfg.AdminDisplayName, Status: "active"}
|
||||||
|
if err := tx.Create(&user).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
} else if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var member model.TenantMember
|
||||||
|
return tx.Where("tenant_id = ? AND user_id = ?", tenant.ID, user.ID).FirstOrCreate(&member, model.TenantMember{TenantID: tenant.ID, UserID: user.ID, RoleID: role.ID, Status: "active"}).Error
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func tokenHash(value string) string {
|
||||||
|
sum := sha256.Sum256([]byte(value))
|
||||||
|
return hex.EncodeToString(sum[:])
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseUint(value string) (uint64, error) {
|
||||||
|
var result uint64
|
||||||
|
_, err := fmt.Sscanf(value, "%d", &result)
|
||||||
|
return result, err
|
||||||
|
}
|
||||||
134
codes/internal/config/config.go
Normal file
134
codes/internal/config/config.go
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/ilyakaznacheev/cleanenv"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
|
App AppConfig `yaml:"app"`
|
||||||
|
Server ServerConfig `yaml:"server"`
|
||||||
|
Database DatabaseConfig `yaml:"database"`
|
||||||
|
Auth AuthConfig `yaml:"auth"`
|
||||||
|
Seed SeedConfig `yaml:"seed"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type AppConfig struct {
|
||||||
|
Name string `yaml:"name" env:"APP_NAME" env-default:"iqudo-top1"`
|
||||||
|
Env string `yaml:"env" env:"APP_ENV" env-default:"development"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ServerConfig struct {
|
||||||
|
Host string `yaml:"host" env:"APP_SERVER_HOST" env-default:"127.0.0.1"`
|
||||||
|
Port int `yaml:"port" env:"APP_SERVER_PORT" env-default:"8080"`
|
||||||
|
ReadTimeout time.Duration `yaml:"read_timeout" env:"APP_SERVER_READ_TIMEOUT" env-default:"10s"`
|
||||||
|
WriteTimeout time.Duration `yaml:"write_timeout" env:"APP_SERVER_WRITE_TIMEOUT" env-default:"20s"`
|
||||||
|
ShutdownTimeout time.Duration `yaml:"shutdown_timeout" env:"APP_SERVER_SHUTDOWN_TIMEOUT" env-default:"10s"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c ServerConfig) Address() string {
|
||||||
|
return fmt.Sprintf("%s:%d", c.Host, c.Port)
|
||||||
|
}
|
||||||
|
|
||||||
|
type DatabaseConfig struct {
|
||||||
|
Host string `yaml:"host" env:"APP_DATABASE_HOST" env-default:"127.0.0.1"`
|
||||||
|
Port int `yaml:"port" env:"APP_DATABASE_PORT" env-default:"3306"`
|
||||||
|
Name string `yaml:"name" env:"APP_DATABASE_NAME"`
|
||||||
|
User string `yaml:"user" env:"APP_DATABASE_USER"`
|
||||||
|
Password string `yaml:"password" env:"APP_DATABASE_PASSWORD"`
|
||||||
|
MaxIdleConnections int `yaml:"max_idle_connections" env:"APP_DATABASE_MAX_IDLE_CONNECTIONS" env-default:"10"`
|
||||||
|
MaxOpenConnections int `yaml:"max_open_connections" env:"APP_DATABASE_MAX_OPEN_CONNECTIONS" env-default:"40"`
|
||||||
|
ConnectionMaxLifetime time.Duration `yaml:"connection_max_lifetime" env:"APP_DATABASE_CONNECTION_MAX_LIFETIME" env-default:"30m"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c DatabaseConfig) DSN() string {
|
||||||
|
return fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=utf8mb4&parseTime=True&loc=Local&multiStatements=true", c.User, c.Password, c.Host, c.Port, c.Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
type AuthConfig struct {
|
||||||
|
JWTSecret string `yaml:"jwt_secret" env:"APP_AUTH_JWT_SECRET"`
|
||||||
|
AccessTokenTTL time.Duration `yaml:"access_token_ttl" env:"APP_AUTH_ACCESS_TOKEN_TTL" env-default:"2h"`
|
||||||
|
RefreshTokenTTL time.Duration `yaml:"refresh_token_ttl" env:"APP_AUTH_REFRESH_TOKEN_TTL" env-default:"168h"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type SeedConfig struct {
|
||||||
|
AdminUsername string `yaml:"admin_username" env:"APP_SEED_ADMIN_USERNAME"`
|
||||||
|
AdminPassword string `yaml:"admin_password" env:"APP_SEED_ADMIN_PASSWORD"`
|
||||||
|
AdminDisplayName string `yaml:"admin_display_name" env:"APP_SEED_ADMIN_DISPLAY_NAME"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type LoadOptions struct {
|
||||||
|
Environment string
|
||||||
|
ConfigDir string
|
||||||
|
}
|
||||||
|
|
||||||
|
func Load(options LoadOptions) (Config, error) {
|
||||||
|
configDir := options.ConfigDir
|
||||||
|
if configDir == "" {
|
||||||
|
configDir = "configs"
|
||||||
|
}
|
||||||
|
|
||||||
|
environment := normalizeEnvironment(options.Environment)
|
||||||
|
if environment == "" {
|
||||||
|
environment = normalizeEnvironment(os.Getenv("APP_ENV"))
|
||||||
|
}
|
||||||
|
|
||||||
|
var cfg Config
|
||||||
|
basePath := filepath.Join(configDir, "config.yml")
|
||||||
|
if err := cleanenv.ReadConfig(basePath, &cfg); err != nil {
|
||||||
|
return Config{}, fmt.Errorf("load base config %s: %w", basePath, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if environment == "test" || environment == "prod" {
|
||||||
|
profilePath := filepath.Join(configDir, "config."+environment+".yml")
|
||||||
|
if err := cleanenv.ReadConfig(profilePath, &cfg); err != nil {
|
||||||
|
return Config{}, fmt.Errorf("load profile config %s: %w", profilePath, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := cleanenv.ReadEnv(&cfg); err != nil {
|
||||||
|
return Config{}, fmt.Errorf("load environment variables: %w", err)
|
||||||
|
}
|
||||||
|
if environment != "" {
|
||||||
|
cfg.App.Env = environment
|
||||||
|
}
|
||||||
|
if err := cfg.Validate(); err != nil {
|
||||||
|
return Config{}, err
|
||||||
|
}
|
||||||
|
return cfg, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeEnvironment(value string) string {
|
||||||
|
switch strings.ToLower(strings.TrimSpace(value)) {
|
||||||
|
case "production", "prod":
|
||||||
|
return "prod"
|
||||||
|
case "testing", "test":
|
||||||
|
return "test"
|
||||||
|
case "development", "dev", "local":
|
||||||
|
return "development"
|
||||||
|
default:
|
||||||
|
return strings.ToLower(strings.TrimSpace(value))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c Config) Validate() error {
|
||||||
|
if c.Database.Name == "" || c.Database.User == "" {
|
||||||
|
return errors.New("database name and user are required")
|
||||||
|
}
|
||||||
|
if len(c.Auth.JWTSecret) < 16 {
|
||||||
|
return errors.New("auth.jwt_secret must contain at least 16 characters")
|
||||||
|
}
|
||||||
|
if c.Server.Port < 1 || c.Server.Port > 65535 {
|
||||||
|
return errors.New("server.port must be between 1 and 65535")
|
||||||
|
}
|
||||||
|
if c.App.Env == "prod" && c.Database.Password == "" {
|
||||||
|
return errors.New("database password is required in production")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
40
codes/internal/dashboard/handler.go
Normal file
40
codes/internal/dashboard/handler.go
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
package dashboard
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"git.iwork-ai.com/xdc/iqudo-top1/internal/auth"
|
||||||
|
"git.iwork-ai.com/xdc/iqudo-top1/internal/model"
|
||||||
|
"git.iwork-ai.com/xdc/iqudo-top1/internal/response"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Handler struct{ db *gorm.DB }
|
||||||
|
|
||||||
|
func NewHandler(db *gorm.DB) *Handler { return &Handler{db: db} }
|
||||||
|
|
||||||
|
func (h *Handler) Summary(c *gin.Context) {
|
||||||
|
p, _ := auth.PrincipalFromContext(c)
|
||||||
|
counts := map[string]int64{}
|
||||||
|
queries := []struct {
|
||||||
|
key string
|
||||||
|
model interface{}
|
||||||
|
where string
|
||||||
|
args []interface{}
|
||||||
|
}{
|
||||||
|
{key: "scenarios", model: &model.Scenario{}, where: "tenant_id = ? AND status <> ?", args: []interface{}{p.TenantID, "archived"}},
|
||||||
|
{key: "published_sops", model: &model.SOP{}, where: "tenant_id = ? AND status = ?", args: []interface{}{p.TenantID, "published"}},
|
||||||
|
{key: "runs", model: &model.SOPRun{}, where: "tenant_id = ?", args: []interface{}{p.TenantID}},
|
||||||
|
{key: "completed_runs", model: &model.SOPRun{}, where: "tenant_id = ? AND status = ?", args: []interface{}{p.TenantID, "completed"}},
|
||||||
|
}
|
||||||
|
for _, query := range queries {
|
||||||
|
var count int64
|
||||||
|
if err := h.db.Model(query.model).Where(query.where, query.args...).Count(&count).Error; err != nil {
|
||||||
|
response.Error(c, http.StatusInternalServerError, "QUERY_FAILED", "查询统计数据失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
counts[query.key] = count
|
||||||
|
}
|
||||||
|
response.OK(c, counts)
|
||||||
|
}
|
||||||
86
codes/internal/database/database.go
Normal file
86
codes/internal/database/database.go
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
package database
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.iwork-ai.com/xdc/iqudo-top1/internal/config"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
"gorm.io/driver/mysql"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
gormlogger "gorm.io/gorm/logger"
|
||||||
|
)
|
||||||
|
|
||||||
|
func Open(cfg config.DatabaseConfig, log *zap.Logger) (*gorm.DB, error) {
|
||||||
|
db, err := gorm.Open(mysql.Open(cfg.DSN()), &gorm.Config{
|
||||||
|
Logger: newGORMLogger(log.Named("gorm"), gormlogger.Warn),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("open mysql: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
sqlDB, err := db.DB()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("get sql database: %w", err)
|
||||||
|
}
|
||||||
|
sqlDB.SetMaxIdleConns(cfg.MaxIdleConnections)
|
||||||
|
sqlDB.SetMaxOpenConns(cfg.MaxOpenConnections)
|
||||||
|
sqlDB.SetConnMaxLifetime(cfg.ConnectionMaxLifetime)
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
if err := sqlDB.PingContext(ctx); err != nil {
|
||||||
|
return nil, fmt.Errorf("ping mysql: %w", err)
|
||||||
|
}
|
||||||
|
return db, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type zapGORMLogger struct {
|
||||||
|
log *zap.Logger
|
||||||
|
level gormlogger.LogLevel
|
||||||
|
}
|
||||||
|
|
||||||
|
func newGORMLogger(log *zap.Logger, level gormlogger.LogLevel) gormlogger.Interface {
|
||||||
|
return &zapGORMLogger{log: log, level: level}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *zapGORMLogger) LogMode(level gormlogger.LogLevel) gormlogger.Interface {
|
||||||
|
clone := *l
|
||||||
|
clone.level = level
|
||||||
|
return &clone
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *zapGORMLogger) Info(_ context.Context, msg string, data ...interface{}) {
|
||||||
|
if l.level >= gormlogger.Info {
|
||||||
|
l.log.Sugar().Infof(msg, data...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *zapGORMLogger) Warn(_ context.Context, msg string, data ...interface{}) {
|
||||||
|
if l.level >= gormlogger.Warn {
|
||||||
|
l.log.Sugar().Warnf(msg, data...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *zapGORMLogger) Error(_ context.Context, msg string, data ...interface{}) {
|
||||||
|
if l.level >= gormlogger.Error {
|
||||||
|
l.log.Sugar().Errorf(msg, data...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *zapGORMLogger) Trace(_ context.Context, begin time.Time, fc func() (string, int64), err error) {
|
||||||
|
if l.level == gormlogger.Silent {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sql, rows := fc()
|
||||||
|
fields := []zap.Field{zap.Duration("duration", time.Since(begin)), zap.Int64("rows", rows), zap.String("sql", sql)}
|
||||||
|
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) && l.level >= gormlogger.Error {
|
||||||
|
l.log.Error("query failed", append(fields, zap.Error(err))...)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if l.level >= gormlogger.Info {
|
||||||
|
l.log.Debug("query", fields...)
|
||||||
|
}
|
||||||
|
}
|
||||||
114
codes/internal/httpserver/server.go
Normal file
114
codes/internal/httpserver/server.go
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
package httpserver
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io/fs"
|
||||||
|
"mime"
|
||||||
|
"net/http"
|
||||||
|
"path"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"git.iwork-ai.com/xdc/iqudo-top1/internal/auth"
|
||||||
|
"git.iwork-ai.com/xdc/iqudo-top1/internal/dashboard"
|
||||||
|
"git.iwork-ai.com/xdc/iqudo-top1/internal/knowledge"
|
||||||
|
"git.iwork-ai.com/xdc/iqudo-top1/internal/middleware"
|
||||||
|
runhandler "git.iwork-ai.com/xdc/iqudo-top1/internal/run"
|
||||||
|
"git.iwork-ai.com/xdc/iqudo-top1/internal/scenario"
|
||||||
|
"git.iwork-ai.com/xdc/iqudo-top1/internal/sop"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
func New(db *gorm.DB, authService *auth.Service, frontend fs.FS, log *zap.Logger, environment string) http.Handler {
|
||||||
|
if environment == "prod" {
|
||||||
|
gin.SetMode(gin.ReleaseMode)
|
||||||
|
}
|
||||||
|
router := gin.New()
|
||||||
|
router.Use(middleware.RequestLogger(log.Named("http")), middleware.Recovery(log.Named("recovery")))
|
||||||
|
|
||||||
|
authHandler := auth.NewHandler(authService)
|
||||||
|
scenarioHandler := scenario.NewHandler(db)
|
||||||
|
sopHandler := sop.NewHandler(db)
|
||||||
|
runHandler := runhandler.NewHandler(db)
|
||||||
|
knowledgeHandler := knowledge.NewHandler(db)
|
||||||
|
dashboardHandler := dashboard.NewHandler(db)
|
||||||
|
|
||||||
|
api := router.Group("/api/v1")
|
||||||
|
api.GET("/health", func(c *gin.Context) {
|
||||||
|
c.JSON(http.StatusOK, gin.H{"status": "ok"})
|
||||||
|
})
|
||||||
|
api.POST("/auth/login", authHandler.Login)
|
||||||
|
api.POST("/auth/refresh", authHandler.Refresh)
|
||||||
|
|
||||||
|
protected := api.Group("")
|
||||||
|
protected.Use(middleware.Authenticate(authService))
|
||||||
|
protected.GET("/auth/me", authHandler.Me)
|
||||||
|
protected.GET("/dashboard/summary", dashboardHandler.Summary)
|
||||||
|
|
||||||
|
protected.GET("/scenarios", scenarioHandler.List)
|
||||||
|
protected.POST("/scenarios", scenarioHandler.Create)
|
||||||
|
protected.GET("/scenarios/:id", scenarioHandler.Get)
|
||||||
|
protected.PUT("/scenarios/:id", scenarioHandler.Update)
|
||||||
|
protected.DELETE("/scenarios/:id", scenarioHandler.Archive)
|
||||||
|
protected.POST("/scenarios/:id/fields", scenarioHandler.CreateField)
|
||||||
|
protected.PUT("/scenario-fields/:fieldId", scenarioHandler.UpdateField)
|
||||||
|
protected.DELETE("/scenario-fields/:fieldId", scenarioHandler.DeleteField)
|
||||||
|
|
||||||
|
protected.GET("/sops", sopHandler.List)
|
||||||
|
protected.GET("/scenarios/:id/sops", sopHandler.List)
|
||||||
|
protected.POST("/scenarios/:id/sops", sopHandler.Create)
|
||||||
|
protected.GET("/sops/:id", sopHandler.Get)
|
||||||
|
protected.PUT("/sops/:id/draft", sopHandler.SaveGraph)
|
||||||
|
protected.POST("/sops/:id/validate", sopHandler.Validate)
|
||||||
|
protected.POST("/sops/:id/submit-review", sopHandler.SubmitReview)
|
||||||
|
protected.POST("/sops/:id/publish", sopHandler.Publish)
|
||||||
|
protected.POST("/sops/:id/offline", sopHandler.Offline)
|
||||||
|
protected.POST("/sops/:id/versions", sopHandler.CreateVersion)
|
||||||
|
|
||||||
|
protected.GET("/published-sops", runHandler.PublishedSOPs)
|
||||||
|
protected.GET("/runs", runHandler.List)
|
||||||
|
protected.POST("/runs", runHandler.Start)
|
||||||
|
protected.GET("/runs/:id", runHandler.Get)
|
||||||
|
protected.POST("/runs/:id/answer", runHandler.Answer)
|
||||||
|
protected.POST("/runs/:id/finish", runHandler.Finish)
|
||||||
|
protected.POST("/runs/:id/feedback", runHandler.Feedback)
|
||||||
|
|
||||||
|
protected.GET("/knowledge-cards", knowledgeHandler.List)
|
||||||
|
protected.POST("/knowledge-cards", knowledgeHandler.Create)
|
||||||
|
protected.DELETE("/knowledge-cards/:id", knowledgeHandler.Delete)
|
||||||
|
|
||||||
|
router.NoRoute(spaHandler(frontend))
|
||||||
|
return router
|
||||||
|
}
|
||||||
|
|
||||||
|
func spaHandler(frontend fs.FS) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
if strings.HasPrefix(c.Request.URL.Path, "/api/") {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"code": "NOT_FOUND", "message": "接口不存在"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
name := strings.TrimPrefix(path.Clean(c.Request.URL.Path), "/")
|
||||||
|
if name == "" || name == "." {
|
||||||
|
name = "index.html"
|
||||||
|
}
|
||||||
|
data, err := fs.ReadFile(frontend, name)
|
||||||
|
if err != nil {
|
||||||
|
name = "index.html"
|
||||||
|
data, err = fs.ReadFile(frontend, name)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
c.Status(http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
contentType := mime.TypeByExtension(path.Ext(name))
|
||||||
|
if contentType == "" {
|
||||||
|
contentType = "application/octet-stream"
|
||||||
|
}
|
||||||
|
if name == "index.html" {
|
||||||
|
c.Header("Cache-Control", "no-cache")
|
||||||
|
} else {
|
||||||
|
c.Header("Cache-Control", "public, max-age=31536000, immutable")
|
||||||
|
}
|
||||||
|
c.Data(http.StatusOK, contentType, data)
|
||||||
|
}
|
||||||
|
}
|
||||||
79
codes/internal/knowledge/handler.go
Normal file
79
codes/internal/knowledge/handler.go
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
package knowledge
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"git.iwork-ai.com/xdc/iqudo-top1/internal/audit"
|
||||||
|
"git.iwork-ai.com/xdc/iqudo-top1/internal/auth"
|
||||||
|
"git.iwork-ai.com/xdc/iqudo-top1/internal/model"
|
||||||
|
"git.iwork-ai.com/xdc/iqudo-top1/internal/response"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"gorm.io/datatypes"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Handler struct{ db *gorm.DB }
|
||||||
|
|
||||||
|
func NewHandler(db *gorm.DB) *Handler { return &Handler{db: db} }
|
||||||
|
|
||||||
|
type input struct {
|
||||||
|
ScenarioID uint64 `json:"scenario_id" binding:"required"`
|
||||||
|
Title string `json:"title" binding:"required,max=128"`
|
||||||
|
Content map[string]interface{} `json:"content" binding:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) List(c *gin.Context) {
|
||||||
|
p, _ := auth.PrincipalFromContext(c)
|
||||||
|
type row struct {
|
||||||
|
model.KnowledgeCard
|
||||||
|
Content datatypes.JSON `json:"content"`
|
||||||
|
}
|
||||||
|
var items []row
|
||||||
|
err := h.db.Table("knowledge_cards kc").Select("kc.*, kcv.content").Joins("LEFT JOIN knowledge_card_versions kcv ON kcv.knowledge_card_id = kc.id AND kcv.status = ?", "published").Where("kc.tenant_id = ?", p.TenantID).Order("kc.updated_at DESC").Scan(&items).Error
|
||||||
|
if err != nil {
|
||||||
|
response.Error(c, http.StatusInternalServerError, "QUERY_FAILED", "查询知识卡失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.OK(c, gin.H{"items": items, "total": len(items)})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) Create(c *gin.Context) {
|
||||||
|
p, _ := auth.PrincipalFromContext(c)
|
||||||
|
var body input
|
||||||
|
if err := c.ShouldBindJSON(&body); err != nil {
|
||||||
|
response.Error(c, http.StatusBadRequest, "INVALID_ARGUMENT", "知识卡内容不完整")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
content, _ := json.Marshal(body.Content)
|
||||||
|
var card model.KnowledgeCard
|
||||||
|
err := h.db.Transaction(func(tx *gorm.DB) error {
|
||||||
|
card = model.KnowledgeCard{TenantID: p.TenantID, ScenarioID: body.ScenarioID, Title: body.Title, Status: "published", CreatedBy: p.UserID}
|
||||||
|
if err := tx.Create(&card).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return tx.Create(&model.KnowledgeCardVersion{TenantID: p.TenantID, KnowledgeCardID: card.ID, Version: 1, Content: datatypes.JSON(content), Status: "published"}).Error
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
response.Error(c, http.StatusInternalServerError, "CREATE_FAILED", "创建知识卡失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = audit.Record(h.db, p, "create", "knowledge_card", card.ID, body)
|
||||||
|
response.Created(c, card)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) Delete(c *gin.Context) {
|
||||||
|
p, _ := auth.PrincipalFromContext(c)
|
||||||
|
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
response.Error(c, http.StatusBadRequest, "INVALID_ID", "知识卡 ID 不正确")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
result := h.db.Model(&model.KnowledgeCard{}).Where("id = ? AND tenant_id = ?", id, p.TenantID).Update("status", "archived")
|
||||||
|
if result.Error != nil || result.RowsAffected == 0 {
|
||||||
|
response.Error(c, http.StatusNotFound, "NOT_FOUND", "知识卡不存在")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.OK(c, gin.H{"id": id})
|
||||||
|
}
|
||||||
28
codes/internal/logger/logger.go
Normal file
28
codes/internal/logger/logger.go
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
package logger
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"go.uber.org/zap"
|
||||||
|
"go.uber.org/zap/zapcore"
|
||||||
|
)
|
||||||
|
|
||||||
|
func New(environment, service string) (*zap.Logger, error) {
|
||||||
|
var cfg zap.Config
|
||||||
|
if strings.EqualFold(environment, "prod") || strings.EqualFold(environment, "production") {
|
||||||
|
cfg = zap.NewProductionConfig()
|
||||||
|
cfg.EncoderConfig.TimeKey = "time"
|
||||||
|
cfg.EncoderConfig.EncodeTime = zapcore.ISO8601TimeEncoder
|
||||||
|
} else {
|
||||||
|
cfg = zap.NewDevelopmentConfig()
|
||||||
|
cfg.EncoderConfig.EncodeLevel = zapcore.CapitalColorLevelEncoder
|
||||||
|
}
|
||||||
|
cfg.OutputPaths = []string{"stdout"}
|
||||||
|
cfg.ErrorOutputPaths = []string{"stderr"}
|
||||||
|
|
||||||
|
log, err := cfg.Build()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return log.With(zap.String("service", service), zap.String("env", environment)), nil
|
||||||
|
}
|
||||||
69
codes/internal/middleware/http.go
Normal file
69
codes/internal/middleware/http.go
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"runtime/debug"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.iwork-ai.com/xdc/iqudo-top1/internal/auth"
|
||||||
|
"git.iwork-ai.com/xdc/iqudo-top1/internal/response"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
func RequestLogger(log *zap.Logger) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
started := time.Now()
|
||||||
|
requestID := c.GetHeader("X-Request-ID")
|
||||||
|
if requestID == "" {
|
||||||
|
requestID = uuid.NewString()
|
||||||
|
}
|
||||||
|
c.Set("request_id", requestID)
|
||||||
|
c.Header("X-Request-ID", requestID)
|
||||||
|
c.Next()
|
||||||
|
|
||||||
|
fields := []zap.Field{
|
||||||
|
zap.String("request_id", requestID), zap.String("method", c.Request.Method), zap.String("path", c.Request.URL.Path),
|
||||||
|
zap.Int("status", c.Writer.Status()), zap.Int("response_bytes", c.Writer.Size()), zap.Duration("duration", time.Since(started)), zap.String("client_ip", c.ClientIP()),
|
||||||
|
}
|
||||||
|
if principal, ok := auth.PrincipalFromContext(c); ok {
|
||||||
|
fields = append(fields, zap.Uint64("tenant_id", principal.TenantID), zap.Uint64("user_id", principal.UserID))
|
||||||
|
}
|
||||||
|
if len(c.Errors) > 0 {
|
||||||
|
fields = append(fields, zap.String("errors", c.Errors.String()))
|
||||||
|
}
|
||||||
|
log.Info("http request", fields...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func Recovery(log *zap.Logger) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
defer func() {
|
||||||
|
if recovered := recover(); recovered != nil {
|
||||||
|
log.Error("panic recovered", zap.Any("panic", recovered), zap.ByteString("stack", debug.Stack()))
|
||||||
|
response.Error(c, http.StatusInternalServerError, "INTERNAL_ERROR", "服务暂时不可用")
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
c.Next()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func Authenticate(service *auth.Service) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
header := c.GetHeader("Authorization")
|
||||||
|
parts := strings.SplitN(header, " ", 2)
|
||||||
|
if len(parts) != 2 || !strings.EqualFold(parts[0], "Bearer") {
|
||||||
|
response.Error(c, http.StatusUnauthorized, "UNAUTHORIZED", "请先登录")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
principal, err := service.ParseAccessToken(parts[1])
|
||||||
|
if err != nil {
|
||||||
|
response.Error(c, http.StatusUnauthorized, "INVALID_TOKEN", "登录状态已失效")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
auth.SetPrincipal(c, principal)
|
||||||
|
c.Next()
|
||||||
|
}
|
||||||
|
}
|
||||||
35
codes/internal/migration/migration.go
Normal file
35
codes/internal/migration/migration.go
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
package migration
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io/fs"
|
||||||
|
|
||||||
|
"github.com/golang-migrate/migrate/v4"
|
||||||
|
migratemysql "github.com/golang-migrate/migrate/v4/database/mysql"
|
||||||
|
"github.com/golang-migrate/migrate/v4/source/iofs"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
func Run(db *gorm.DB, files fs.FS) error {
|
||||||
|
sqlDB, err := db.DB()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("get sql database: %w", err)
|
||||||
|
}
|
||||||
|
driver, err := migratemysql.WithInstance(sqlDB, &migratemysql.Config{})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("create migration database driver: %w", err)
|
||||||
|
}
|
||||||
|
source, err := iofs.New(files, "migrations")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("create migration source: %w", err)
|
||||||
|
}
|
||||||
|
m, err := migrate.NewWithInstance("iofs", source, "mysql", driver)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("create migrator: %w", err)
|
||||||
|
}
|
||||||
|
if err := m.Up(); err != nil && !errors.Is(err, migrate.ErrNoChange) {
|
||||||
|
return fmt.Errorf("run migrations: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
186
codes/internal/model/models.go
Normal file
186
codes/internal/model/models.go
Normal file
@@ -0,0 +1,186 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gorm.io/datatypes"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Base struct {
|
||||||
|
ID uint64 `json:"id" gorm:"primaryKey;autoIncrement"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Tenant struct {
|
||||||
|
Base
|
||||||
|
Name string `json:"name" gorm:"size:128;not null"`
|
||||||
|
Slug string `json:"slug" gorm:"size:64;not null;uniqueIndex"`
|
||||||
|
Status string `json:"status" gorm:"size:24;not null"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type User struct {
|
||||||
|
Base
|
||||||
|
Username string `json:"username" gorm:"size:64;not null;uniqueIndex"`
|
||||||
|
PasswordHash string `json:"-" gorm:"size:255;not null"`
|
||||||
|
DisplayName string `json:"display_name" gorm:"size:128;not null"`
|
||||||
|
Status string `json:"status" gorm:"size:24;not null"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Role struct {
|
||||||
|
Base
|
||||||
|
TenantID uint64 `json:"tenant_id" gorm:"not null;index"`
|
||||||
|
Name string `json:"name" gorm:"size:64;not null"`
|
||||||
|
Code string `json:"code" gorm:"size:64;not null"`
|
||||||
|
Permissions datatypes.JSON `json:"permissions" gorm:"type:json;not null"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type TenantMember struct {
|
||||||
|
Base
|
||||||
|
TenantID uint64 `json:"tenant_id" gorm:"not null;index"`
|
||||||
|
UserID uint64 `json:"user_id" gorm:"not null;index"`
|
||||||
|
RoleID uint64 `json:"role_id" gorm:"not null;index"`
|
||||||
|
Status string `json:"status" gorm:"size:24;not null"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Scenario struct {
|
||||||
|
Base
|
||||||
|
TenantID uint64 `json:"tenant_id" gorm:"not null;index"`
|
||||||
|
Name string `json:"name" gorm:"size:128;not null"`
|
||||||
|
Industry string `json:"industry" gorm:"size:64;not null"`
|
||||||
|
RoleName string `json:"role_name" gorm:"size:64;not null"`
|
||||||
|
Goal string `json:"goal" gorm:"type:text;not null"`
|
||||||
|
TriggerText string `json:"trigger_text" gorm:"type:text;not null"`
|
||||||
|
Visibility string `json:"visibility" gorm:"size:24;not null"`
|
||||||
|
Status string `json:"status" gorm:"size:24;not null"`
|
||||||
|
CreatedBy uint64 `json:"created_by" gorm:"not null"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ScenarioField struct {
|
||||||
|
Base
|
||||||
|
TenantID uint64 `json:"tenant_id" gorm:"not null;index"`
|
||||||
|
ScenarioID uint64 `json:"scenario_id" gorm:"not null;index"`
|
||||||
|
FieldKey string `json:"field_key" gorm:"size:64;not null"`
|
||||||
|
FieldName string `json:"field_name" gorm:"size:128;not null"`
|
||||||
|
FieldType string `json:"field_type" gorm:"size:32;not null"`
|
||||||
|
Required bool `json:"required" gorm:"not null"`
|
||||||
|
Options datatypes.JSON `json:"options" gorm:"type:json;not null"`
|
||||||
|
Validation datatypes.JSON `json:"validation" gorm:"type:json;not null"`
|
||||||
|
SortOrder int `json:"sort_order" gorm:"not null"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type SOP struct {
|
||||||
|
Base
|
||||||
|
TenantID uint64 `json:"tenant_id" gorm:"not null;index"`
|
||||||
|
ScenarioID uint64 `json:"scenario_id" gorm:"not null;index"`
|
||||||
|
Name string `json:"name" gorm:"size:128;not null"`
|
||||||
|
Description string `json:"description" gorm:"type:text;not null"`
|
||||||
|
Status string `json:"status" gorm:"size:24;not null"`
|
||||||
|
CreatedBy uint64 `json:"created_by" gorm:"not null"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type SOPVersion struct {
|
||||||
|
Base
|
||||||
|
TenantID uint64 `json:"tenant_id" gorm:"not null;index"`
|
||||||
|
SOPID uint64 `json:"sop_id" gorm:"not null;index"`
|
||||||
|
Version int `json:"version" gorm:"not null"`
|
||||||
|
Status string `json:"status" gorm:"size:24;not null"`
|
||||||
|
StartNodeKey string `json:"start_node_key" gorm:"size:64;not null"`
|
||||||
|
PublishedAt *time.Time `json:"published_at"`
|
||||||
|
CreatedBy uint64 `json:"created_by" gorm:"not null"`
|
||||||
|
ReviewedBy *uint64 `json:"reviewed_by"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type SOPNode struct {
|
||||||
|
Base
|
||||||
|
TenantID uint64 `json:"tenant_id" gorm:"not null;index"`
|
||||||
|
SOPVersionID uint64 `json:"sop_version_id" gorm:"not null;index"`
|
||||||
|
NodeKey string `json:"node_key" gorm:"size:64;not null"`
|
||||||
|
Type string `json:"type" gorm:"size:32;not null"`
|
||||||
|
Title string `json:"title" gorm:"size:128;not null"`
|
||||||
|
Content string `json:"content" gorm:"type:text;not null"`
|
||||||
|
Config datatypes.JSON `json:"config" gorm:"type:json;not null"`
|
||||||
|
PositionX int `json:"position_x" gorm:"not null"`
|
||||||
|
PositionY int `json:"position_y" gorm:"not null"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type SOPEdge struct {
|
||||||
|
Base
|
||||||
|
TenantID uint64 `json:"tenant_id" gorm:"not null;index"`
|
||||||
|
SOPVersionID uint64 `json:"sop_version_id" gorm:"not null;index"`
|
||||||
|
SourceNodeKey string `json:"source_node_key" gorm:"size:64;not null"`
|
||||||
|
TargetNodeKey string `json:"target_node_key" gorm:"size:64;not null"`
|
||||||
|
Condition datatypes.JSON `json:"condition" gorm:"type:json;not null"`
|
||||||
|
Priority int `json:"priority" gorm:"not null"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type KnowledgeCard struct {
|
||||||
|
Base
|
||||||
|
TenantID uint64 `json:"tenant_id" gorm:"not null;index"`
|
||||||
|
ScenarioID uint64 `json:"scenario_id" gorm:"not null;index"`
|
||||||
|
Title string `json:"title" gorm:"size:128;not null"`
|
||||||
|
Status string `json:"status" gorm:"size:24;not null"`
|
||||||
|
CreatedBy uint64 `json:"created_by" gorm:"not null"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type KnowledgeCardVersion struct {
|
||||||
|
Base
|
||||||
|
TenantID uint64 `json:"tenant_id" gorm:"not null;index"`
|
||||||
|
KnowledgeCardID uint64 `json:"knowledge_card_id" gorm:"not null;index"`
|
||||||
|
Version int `json:"version" gorm:"not null"`
|
||||||
|
Content datatypes.JSON `json:"content" gorm:"type:json;not null"`
|
||||||
|
Status string `json:"status" gorm:"size:24;not null"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type SOPRun struct {
|
||||||
|
Base
|
||||||
|
TenantID uint64 `json:"tenant_id" gorm:"not null;index"`
|
||||||
|
SOPID uint64 `json:"sop_id" gorm:"not null;index"`
|
||||||
|
SOPVersionID uint64 `json:"sop_version_id" gorm:"not null;index"`
|
||||||
|
OperatorID uint64 `json:"operator_id" gorm:"not null;index"`
|
||||||
|
CurrentNodeKey string `json:"current_node_key" gorm:"size:64;not null"`
|
||||||
|
Status string `json:"status" gorm:"size:24;not null"`
|
||||||
|
Answers datatypes.JSON `json:"answers" gorm:"type:json;not null"`
|
||||||
|
Result string `json:"result" gorm:"size:64;not null"`
|
||||||
|
StartedAt time.Time `json:"started_at"`
|
||||||
|
CompletedAt *time.Time `json:"completed_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type SOPRunEvent struct {
|
||||||
|
Base
|
||||||
|
TenantID uint64 `json:"tenant_id" gorm:"not null;index"`
|
||||||
|
RunID uint64 `json:"run_id" gorm:"not null;index"`
|
||||||
|
NodeKey string `json:"node_key" gorm:"size:64;not null"`
|
||||||
|
Action string `json:"action" gorm:"size:64;not null"`
|
||||||
|
Payload datatypes.JSON `json:"payload" gorm:"type:json;not null"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type SOPFeedback struct {
|
||||||
|
Base
|
||||||
|
TenantID uint64 `json:"tenant_id" gorm:"not null;index"`
|
||||||
|
RunID uint64 `json:"run_id" gorm:"not null;index"`
|
||||||
|
UserID uint64 `json:"user_id" gorm:"not null;index"`
|
||||||
|
Score int `json:"score" gorm:"not null"`
|
||||||
|
Comment string `json:"comment" gorm:"type:text;not null"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (SOPFeedback) TableName() string { return "sop_feedback" }
|
||||||
|
|
||||||
|
type AuditLog struct {
|
||||||
|
Base
|
||||||
|
TenantID uint64 `json:"tenant_id" gorm:"not null;index"`
|
||||||
|
UserID uint64 `json:"user_id" gorm:"not null;index"`
|
||||||
|
Action string `json:"action" gorm:"size:64;not null"`
|
||||||
|
Resource string `json:"resource" gorm:"size:64;not null"`
|
||||||
|
ResourceID uint64 `json:"resource_id" gorm:"not null"`
|
||||||
|
Payload datatypes.JSON `json:"payload" gorm:"type:json;not null"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type RefreshToken struct {
|
||||||
|
Base
|
||||||
|
TenantID uint64 `json:"tenant_id" gorm:"not null;index"`
|
||||||
|
UserID uint64 `json:"user_id" gorm:"not null;index"`
|
||||||
|
TokenHash string `json:"-" gorm:"size:64;not null;uniqueIndex"`
|
||||||
|
ExpiresAt time.Time `json:"expires_at" gorm:"not null"`
|
||||||
|
RevokedAt *time.Time `json:"revoked_at"`
|
||||||
|
}
|
||||||
21
codes/internal/response/response.go
Normal file
21
codes/internal/response/response.go
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
package response
|
||||||
|
|
||||||
|
import "github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
type Envelope struct {
|
||||||
|
Code string `json:"code"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
Data interface{} `json:"data,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func OK(c *gin.Context, data interface{}) {
|
||||||
|
c.JSON(200, Envelope{Code: "OK", Message: "success", Data: data})
|
||||||
|
}
|
||||||
|
|
||||||
|
func Created(c *gin.Context, data interface{}) {
|
||||||
|
c.JSON(201, Envelope{Code: "CREATED", Message: "created", Data: data})
|
||||||
|
}
|
||||||
|
|
||||||
|
func Error(c *gin.Context, status int, code, message string) {
|
||||||
|
c.AbortWithStatusJSON(status, Envelope{Code: code, Message: message})
|
||||||
|
}
|
||||||
129
codes/internal/run/engine.go
Normal file
129
codes/internal/run/engine.go
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
package run
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"reflect"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
func matchCondition(raw json.RawMessage, answers map[string]interface{}) (bool, error) {
|
||||||
|
if len(raw) == 0 || string(raw) == "{}" || string(raw) == "null" {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
var rule map[string]interface{}
|
||||||
|
if err := json.Unmarshal(raw, &rule); err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
if all, ok := rule["all"].([]interface{}); ok {
|
||||||
|
for _, item := range all {
|
||||||
|
object, ok := item.(map[string]interface{})
|
||||||
|
if !ok {
|
||||||
|
return false, fmt.Errorf("invalid all condition")
|
||||||
|
}
|
||||||
|
matched, err := matchRule(object, answers)
|
||||||
|
if err != nil || !matched {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
if any, ok := rule["any"].([]interface{}); ok {
|
||||||
|
for _, item := range any {
|
||||||
|
object, ok := item.(map[string]interface{})
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
matched, err := matchRule(object, answers)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
if matched {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
return matchRule(rule, answers)
|
||||||
|
}
|
||||||
|
|
||||||
|
func matchRule(rule map[string]interface{}, answers map[string]interface{}) (bool, error) {
|
||||||
|
field, _ := rule["field"].(string)
|
||||||
|
operator, _ := rule["operator"].(string)
|
||||||
|
if field == "" || operator == "" {
|
||||||
|
return false, fmt.Errorf("condition field and operator are required")
|
||||||
|
}
|
||||||
|
actual, exists := answers[field]
|
||||||
|
expected := rule["value"]
|
||||||
|
switch operator {
|
||||||
|
case "exists":
|
||||||
|
return exists && actual != nil && fmt.Sprint(actual) != "", nil
|
||||||
|
case "not_exists":
|
||||||
|
return !exists || actual == nil || fmt.Sprint(actual) == "", nil
|
||||||
|
case "equals":
|
||||||
|
return reflect.DeepEqual(normalizeValue(actual), normalizeValue(expected)), nil
|
||||||
|
case "not_equals":
|
||||||
|
return !reflect.DeepEqual(normalizeValue(actual), normalizeValue(expected)), nil
|
||||||
|
case "contains":
|
||||||
|
return strings.Contains(strings.ToLower(fmt.Sprint(actual)), strings.ToLower(fmt.Sprint(expected))), nil
|
||||||
|
case "greater_than", "less_than":
|
||||||
|
left, leftOK := toFloat(actual)
|
||||||
|
right, rightOK := toFloat(expected)
|
||||||
|
if !leftOK || !rightOK {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
if operator == "greater_than" {
|
||||||
|
return left > right, nil
|
||||||
|
}
|
||||||
|
return left < right, nil
|
||||||
|
case "in":
|
||||||
|
values, ok := expected.([]interface{})
|
||||||
|
if !ok {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
for _, value := range values {
|
||||||
|
if reflect.DeepEqual(normalizeValue(actual), normalizeValue(value)) {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false, nil
|
||||||
|
default:
|
||||||
|
return false, fmt.Errorf("unsupported operator: %s", operator)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeValue(value interface{}) interface{} {
|
||||||
|
switch typed := value.(type) {
|
||||||
|
case json.Number:
|
||||||
|
if number, err := typed.Float64(); err == nil {
|
||||||
|
return number
|
||||||
|
}
|
||||||
|
case int:
|
||||||
|
return float64(typed)
|
||||||
|
case int64:
|
||||||
|
return float64(typed)
|
||||||
|
case uint64:
|
||||||
|
return float64(typed)
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
func toFloat(value interface{}) (float64, bool) {
|
||||||
|
switch typed := value.(type) {
|
||||||
|
case float64:
|
||||||
|
return typed, true
|
||||||
|
case float32:
|
||||||
|
return float64(typed), true
|
||||||
|
case int:
|
||||||
|
return float64(typed), true
|
||||||
|
case int64:
|
||||||
|
return float64(typed), true
|
||||||
|
case uint64:
|
||||||
|
return float64(typed), true
|
||||||
|
case json.Number:
|
||||||
|
result, err := typed.Float64()
|
||||||
|
return result, err == nil
|
||||||
|
default:
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
}
|
||||||
33
codes/internal/run/engine_test.go
Normal file
33
codes/internal/run/engine_test.go
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
package run
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestMatchCondition(t *testing.T) {
|
||||||
|
answers := map[string]interface{}{"weight": float64(6), "urgent": true, "symptom": "持续呕吐"}
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
rule string
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{name: "default", rule: `{}`, want: true},
|
||||||
|
{name: "equals", rule: `{"field":"urgent","operator":"equals","value":true}`, want: true},
|
||||||
|
{name: "greater", rule: `{"field":"weight","operator":"greater_than","value":5}`, want: true},
|
||||||
|
{name: "contains", rule: `{"field":"symptom","operator":"contains","value":"呕吐"}`, want: true},
|
||||||
|
{name: "all", rule: `{"all":[{"field":"urgent","operator":"equals","value":true},{"field":"weight","operator":"greater_than","value":5}]}`, want: true},
|
||||||
|
{name: "any false", rule: `{"any":[{"field":"urgent","operator":"equals","value":false},{"field":"weight","operator":"less_than","value":3}]}`, want: false},
|
||||||
|
}
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
got, err := matchCondition(json.RawMessage(test.rule), answers)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("matchCondition returned error: %v", err)
|
||||||
|
}
|
||||||
|
if got != test.want {
|
||||||
|
t.Fatalf("matchCondition() = %v, want %v", got, test.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
247
codes/internal/run/handler.go
Normal file
247
codes/internal/run/handler.go
Normal file
@@ -0,0 +1,247 @@
|
|||||||
|
package run
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.iwork-ai.com/xdc/iqudo-top1/internal/audit"
|
||||||
|
"git.iwork-ai.com/xdc/iqudo-top1/internal/auth"
|
||||||
|
"git.iwork-ai.com/xdc/iqudo-top1/internal/model"
|
||||||
|
"git.iwork-ai.com/xdc/iqudo-top1/internal/response"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"gorm.io/datatypes"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Handler struct {
|
||||||
|
db *gorm.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewHandler(db *gorm.DB) *Handler {
|
||||||
|
return &Handler{db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) PublishedSOPs(c *gin.Context) {
|
||||||
|
p, _ := auth.PrincipalFromContext(c)
|
||||||
|
type item struct {
|
||||||
|
ID uint64 `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
ScenarioID uint64 `json:"scenario_id"`
|
||||||
|
ScenarioName string `json:"scenario_name"`
|
||||||
|
Version int `json:"version"`
|
||||||
|
}
|
||||||
|
var items []item
|
||||||
|
err := h.db.Table("sops s").Select("s.id, s.name, s.description, s.scenario_id, sc.name AS scenario_name, sv.version").Joins("JOIN scenarios sc ON sc.id = s.scenario_id").Joins("JOIN sop_versions sv ON sv.sop_id = s.id AND sv.status = ?", "published").Where("s.tenant_id = ? AND s.status = ?", p.TenantID, "published").Order("s.updated_at DESC").Scan(&items).Error
|
||||||
|
if err != nil {
|
||||||
|
response.Error(c, http.StatusInternalServerError, "QUERY_FAILED", "查询可执行 SOP 失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.OK(c, gin.H{"items": items, "total": len(items)})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) Start(c *gin.Context) {
|
||||||
|
p, _ := auth.PrincipalFromContext(c)
|
||||||
|
var input struct {
|
||||||
|
SOPID uint64 `json:"sop_id" binding:"required"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&input); err != nil {
|
||||||
|
response.Error(c, http.StatusBadRequest, "INVALID_ARGUMENT", "请选择要执行的 SOP")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var version model.SOPVersion
|
||||||
|
if err := h.db.Where("sop_id = ? AND tenant_id = ? AND status = ?", input.SOPID, p.TenantID, "published").Order("version DESC").First(&version).Error; err != nil {
|
||||||
|
response.Error(c, http.StatusNotFound, "NOT_FOUND", "没有可执行的已发布版本")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
run := model.SOPRun{TenantID: p.TenantID, SOPID: input.SOPID, SOPVersionID: version.ID, OperatorID: p.UserID, CurrentNodeKey: version.StartNodeKey, Status: "running", Answers: datatypes.JSON([]byte(`{}`)), Result: "", StartedAt: time.Now()}
|
||||||
|
err := h.db.Transaction(func(tx *gorm.DB) error {
|
||||||
|
if err := tx.Create(&run).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return tx.Create(&model.SOPRunEvent{TenantID: p.TenantID, RunID: run.ID, NodeKey: run.CurrentNodeKey, Action: "start", Payload: datatypes.JSON([]byte(`{}`))}).Error
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
response.Error(c, http.StatusInternalServerError, "START_FAILED", "启动 SOP 失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = audit.Record(h.db, p, "start", "sop_run", run.ID, gin.H{"sop_id": input.SOPID})
|
||||||
|
h.respondRun(c, run)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) Get(c *gin.Context) {
|
||||||
|
p, _ := auth.PrincipalFromContext(c)
|
||||||
|
id, ok := runID(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var item model.SOPRun
|
||||||
|
if err := h.db.Where("id = ? AND tenant_id = ?", id, p.TenantID).First(&item).Error; err != nil {
|
||||||
|
response.Error(c, http.StatusNotFound, "NOT_FOUND", "执行记录不存在")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.respondRun(c, item)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) List(c *gin.Context) {
|
||||||
|
p, _ := auth.PrincipalFromContext(c)
|
||||||
|
type row struct {
|
||||||
|
model.SOPRun
|
||||||
|
SOPName string `json:"sop_name"`
|
||||||
|
}
|
||||||
|
var items []row
|
||||||
|
if err := h.db.Table("sop_runs r").Select("r.*, s.name AS sop_name").Joins("JOIN sops s ON s.id = r.sop_id").Where("r.tenant_id = ?", p.TenantID).Order("r.created_at DESC").Limit(100).Scan(&items).Error; err != nil {
|
||||||
|
response.Error(c, http.StatusInternalServerError, "QUERY_FAILED", "查询执行记录失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.OK(c, gin.H{"items": items, "total": len(items)})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) Answer(c *gin.Context) {
|
||||||
|
p, _ := auth.PrincipalFromContext(c)
|
||||||
|
id, ok := runID(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var input struct {
|
||||||
|
Answers map[string]interface{} `json:"answers"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&input); err != nil {
|
||||||
|
response.Error(c, http.StatusBadRequest, "INVALID_ARGUMENT", "回答格式不正确")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var updated model.SOPRun
|
||||||
|
err := h.db.Transaction(func(tx *gorm.DB) error {
|
||||||
|
if err := tx.Clauses().Where("id = ? AND tenant_id = ?", id, p.TenantID).First(&updated).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if updated.Status != "running" {
|
||||||
|
return errors.New("run is not active")
|
||||||
|
}
|
||||||
|
answers := map[string]interface{}{}
|
||||||
|
if len(updated.Answers) > 0 {
|
||||||
|
_ = json.Unmarshal(updated.Answers, &answers)
|
||||||
|
}
|
||||||
|
for key, value := range input.Answers {
|
||||||
|
answers[key] = value
|
||||||
|
}
|
||||||
|
var edges []model.SOPEdge
|
||||||
|
if err := tx.Where("sop_version_id = ? AND source_node_key = ?", updated.SOPVersionID, updated.CurrentNodeKey).Order("priority, id").Find(&edges).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
sort.SliceStable(edges, func(i, j int) bool { return edges[i].Priority < edges[j].Priority })
|
||||||
|
nextKey := ""
|
||||||
|
for _, edge := range edges {
|
||||||
|
matched, matchErr := matchCondition(json.RawMessage(edge.Condition), answers)
|
||||||
|
if matchErr != nil {
|
||||||
|
return matchErr
|
||||||
|
}
|
||||||
|
if matched {
|
||||||
|
nextKey = edge.TargetNodeKey
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if nextKey == "" {
|
||||||
|
return errors.New("没有满足条件的下一节点")
|
||||||
|
}
|
||||||
|
answerBytes, _ := json.Marshal(answers)
|
||||||
|
payload, _ := json.Marshal(input)
|
||||||
|
if err := tx.Create(&model.SOPRunEvent{TenantID: p.TenantID, RunID: updated.ID, NodeKey: updated.CurrentNodeKey, Action: "answer", Payload: datatypes.JSON(payload)}).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var next model.SOPNode
|
||||||
|
if err := tx.Where("sop_version_id = ? AND node_key = ?", updated.SOPVersionID, nextKey).First(&next).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
updates := map[string]interface{}{"current_node_key": nextKey, "answers": datatypes.JSON(answerBytes)}
|
||||||
|
if next.Type == "finish" || next.Type == "escalate" {
|
||||||
|
now := time.Now()
|
||||||
|
updates["status"] = "completed"
|
||||||
|
updates["completed_at"] = &now
|
||||||
|
updates["result"] = next.Type
|
||||||
|
updated.Status = "completed"
|
||||||
|
updated.CompletedAt = &now
|
||||||
|
updated.Result = next.Type
|
||||||
|
}
|
||||||
|
if err := tx.Model(&updated).Updates(updates).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
updated.CurrentNodeKey = nextKey
|
||||||
|
updated.Answers = datatypes.JSON(answerBytes)
|
||||||
|
return tx.Create(&model.SOPRunEvent{TenantID: p.TenantID, RunID: updated.ID, NodeKey: nextKey, Action: "enter", Payload: datatypes.JSON([]byte(`{}`))}).Error
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
response.Error(c, http.StatusUnprocessableEntity, "ADVANCE_FAILED", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.respondRun(c, updated)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) Finish(c *gin.Context) {
|
||||||
|
p, _ := auth.PrincipalFromContext(c)
|
||||||
|
id, ok := runID(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var input struct {
|
||||||
|
Result string `json:"result" binding:"required,max=64"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&input); err != nil {
|
||||||
|
response.Error(c, http.StatusBadRequest, "INVALID_ARGUMENT", "请选择执行结果")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
now := time.Now()
|
||||||
|
result := h.db.Model(&model.SOPRun{}).Where("id = ? AND tenant_id = ?", id, p.TenantID).Updates(map[string]interface{}{"status": "completed", "result": input.Result, "completed_at": &now})
|
||||||
|
if result.Error != nil || result.RowsAffected == 0 {
|
||||||
|
response.Error(c, http.StatusNotFound, "NOT_FOUND", "执行记录不存在")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = audit.Record(h.db, p, "finish", "sop_run", id, input)
|
||||||
|
h.Get(c)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) Feedback(c *gin.Context) {
|
||||||
|
p, _ := auth.PrincipalFromContext(c)
|
||||||
|
id, ok := runID(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var input struct {
|
||||||
|
Score int `json:"score" binding:"required,min=1,max=5"`
|
||||||
|
Comment string `json:"comment" binding:"max=2000"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&input); err != nil {
|
||||||
|
response.Error(c, http.StatusBadRequest, "INVALID_ARGUMENT", "反馈内容不正确")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
item := model.SOPFeedback{TenantID: p.TenantID, RunID: id, UserID: p.UserID, Score: input.Score, Comment: input.Comment}
|
||||||
|
if err := h.db.Create(&item).Error; err != nil {
|
||||||
|
response.Error(c, http.StatusConflict, "FEEDBACK_EXISTS", "该执行记录已经提交反馈")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.Created(c, item)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) respondRun(c *gin.Context, item model.SOPRun) {
|
||||||
|
var node model.SOPNode
|
||||||
|
if err := h.db.Where("sop_version_id = ? AND node_key = ?", item.SOPVersionID, item.CurrentNodeKey).First(&node).Error; err != nil {
|
||||||
|
response.Error(c, http.StatusInternalServerError, "NODE_NOT_FOUND", "当前流程节点不存在")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var fields []model.ScenarioField
|
||||||
|
h.db.Table("scenario_fields sf").Joins("JOIN sops s ON s.scenario_id = sf.scenario_id").Where("s.id = ? AND sf.tenant_id = ?", item.SOPID, item.TenantID).Order("sf.sort_order, sf.id").Find(&fields)
|
||||||
|
response.OK(c, gin.H{"run": item, "node": node, "fields": fields})
|
||||||
|
}
|
||||||
|
|
||||||
|
func runID(c *gin.Context) (uint64, bool) {
|
||||||
|
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||||
|
if err != nil || id == 0 {
|
||||||
|
response.Error(c, http.StatusBadRequest, "INVALID_ID", "执行记录 ID 不正确")
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
return id, true
|
||||||
|
}
|
||||||
241
codes/internal/scenario/handler.go
Normal file
241
codes/internal/scenario/handler.go
Normal file
@@ -0,0 +1,241 @@
|
|||||||
|
package scenario
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"regexp"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"git.iwork-ai.com/xdc/iqudo-top1/internal/audit"
|
||||||
|
"git.iwork-ai.com/xdc/iqudo-top1/internal/auth"
|
||||||
|
"git.iwork-ai.com/xdc/iqudo-top1/internal/model"
|
||||||
|
"git.iwork-ai.com/xdc/iqudo-top1/internal/response"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"gorm.io/datatypes"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Handler struct {
|
||||||
|
db *gorm.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewHandler(db *gorm.DB) *Handler {
|
||||||
|
return &Handler{db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
type scenarioInput struct {
|
||||||
|
Name string `json:"name" binding:"required,max=128"`
|
||||||
|
Industry string `json:"industry" binding:"required,max=64"`
|
||||||
|
RoleName string `json:"role_name" binding:"required,max=64"`
|
||||||
|
Goal string `json:"goal" binding:"required,max=2000"`
|
||||||
|
TriggerText string `json:"trigger_text" binding:"required,max=2000"`
|
||||||
|
Visibility string `json:"visibility" binding:"omitempty,oneof=private team tenant"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type fieldInput struct {
|
||||||
|
FieldKey string `json:"field_key" binding:"required,max=64"`
|
||||||
|
FieldName string `json:"field_name" binding:"required,max=128"`
|
||||||
|
FieldType string `json:"field_type" binding:"required,oneof=text textarea number boolean select multiselect date"`
|
||||||
|
Required bool `json:"required"`
|
||||||
|
Options json.RawMessage `json:"options"`
|
||||||
|
Validation json.RawMessage `json:"validation"`
|
||||||
|
SortOrder int `json:"sort_order"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var fieldKeyPattern = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9_]*$`)
|
||||||
|
|
||||||
|
func (h *Handler) List(c *gin.Context) {
|
||||||
|
p, _ := auth.PrincipalFromContext(c)
|
||||||
|
var items []model.Scenario
|
||||||
|
query := h.db.Where("tenant_id = ? AND status <> ?", p.TenantID, "archived")
|
||||||
|
if keyword := c.Query("keyword"); keyword != "" {
|
||||||
|
query = query.Where("name LIKE ? OR industry LIKE ?", "%"+keyword+"%", "%"+keyword+"%")
|
||||||
|
}
|
||||||
|
if status := c.Query("status"); status != "" {
|
||||||
|
query = query.Where("status = ?", status)
|
||||||
|
}
|
||||||
|
var total int64
|
||||||
|
if err := query.Model(&model.Scenario{}).Count(&total).Error; err != nil {
|
||||||
|
response.Error(c, http.StatusInternalServerError, "QUERY_FAILED", "查询场景失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := query.Order("updated_at DESC").Find(&items).Error; err != nil {
|
||||||
|
response.Error(c, http.StatusInternalServerError, "QUERY_FAILED", "查询场景失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.OK(c, gin.H{"items": items, "total": total})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) Create(c *gin.Context) {
|
||||||
|
p, _ := auth.PrincipalFromContext(c)
|
||||||
|
var input scenarioInput
|
||||||
|
if err := c.ShouldBindJSON(&input); err != nil {
|
||||||
|
response.Error(c, http.StatusBadRequest, "INVALID_ARGUMENT", "场景信息不完整")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
visibility := input.Visibility
|
||||||
|
if visibility == "" {
|
||||||
|
visibility = "tenant"
|
||||||
|
}
|
||||||
|
item := model.Scenario{TenantID: p.TenantID, Name: input.Name, Industry: input.Industry, RoleName: input.RoleName, Goal: input.Goal, TriggerText: input.TriggerText, Visibility: visibility, Status: "draft", CreatedBy: p.UserID}
|
||||||
|
if err := h.db.Create(&item).Error; err != nil {
|
||||||
|
response.Error(c, http.StatusInternalServerError, "CREATE_FAILED", "创建场景失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = audit.Record(h.db, p, "create", "scenario", item.ID, input)
|
||||||
|
response.Created(c, item)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) Get(c *gin.Context) {
|
||||||
|
p, _ := auth.PrincipalFromContext(c)
|
||||||
|
id, ok := idParam(c, "id")
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var item model.Scenario
|
||||||
|
if err := h.db.Where("id = ? AND tenant_id = ?", id, p.TenantID).First(&item).Error; err != nil {
|
||||||
|
notFound(c, err, "场景不存在")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var fields []model.ScenarioField
|
||||||
|
var sops []model.SOP
|
||||||
|
h.db.Where("scenario_id = ? AND tenant_id = ?", id, p.TenantID).Order("sort_order, id").Find(&fields)
|
||||||
|
h.db.Where("scenario_id = ? AND tenant_id = ?", id, p.TenantID).Order("updated_at DESC").Find(&sops)
|
||||||
|
response.OK(c, gin.H{"scenario": item, "fields": fields, "sops": sops})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) Update(c *gin.Context) {
|
||||||
|
p, _ := auth.PrincipalFromContext(c)
|
||||||
|
id, ok := idParam(c, "id")
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var input scenarioInput
|
||||||
|
if err := c.ShouldBindJSON(&input); err != nil {
|
||||||
|
response.Error(c, http.StatusBadRequest, "INVALID_ARGUMENT", "场景信息不完整")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
updates := map[string]interface{}{"name": input.Name, "industry": input.Industry, "role_name": input.RoleName, "goal": input.Goal, "trigger_text": input.TriggerText, "visibility": input.Visibility}
|
||||||
|
result := h.db.Model(&model.Scenario{}).Where("id = ? AND tenant_id = ? AND status <> ?", id, p.TenantID, "archived").Updates(updates)
|
||||||
|
if result.Error != nil || result.RowsAffected == 0 {
|
||||||
|
response.Error(c, http.StatusNotFound, "NOT_FOUND", "场景不存在")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = audit.Record(h.db, p, "update", "scenario", id, input)
|
||||||
|
h.Get(c)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) Archive(c *gin.Context) {
|
||||||
|
p, _ := auth.PrincipalFromContext(c)
|
||||||
|
id, ok := idParam(c, "id")
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
result := h.db.Model(&model.Scenario{}).Where("id = ? AND tenant_id = ?", id, p.TenantID).Update("status", "archived")
|
||||||
|
if result.Error != nil || result.RowsAffected == 0 {
|
||||||
|
response.Error(c, http.StatusNotFound, "NOT_FOUND", "场景不存在")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = audit.Record(h.db, p, "archive", "scenario", id, nil)
|
||||||
|
response.OK(c, gin.H{"id": id})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) CreateField(c *gin.Context) {
|
||||||
|
p, _ := auth.PrincipalFromContext(c)
|
||||||
|
scenarioID, ok := idParam(c, "id")
|
||||||
|
if !ok || !h.scenarioExists(p.TenantID, scenarioID) {
|
||||||
|
if ok {
|
||||||
|
response.Error(c, http.StatusNotFound, "NOT_FOUND", "场景不存在")
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var input fieldInput
|
||||||
|
if err := c.ShouldBindJSON(&input); err != nil {
|
||||||
|
response.Error(c, http.StatusBadRequest, "INVALID_ARGUMENT", "字段配置不正确")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !fieldKeyPattern.MatchString(input.FieldKey) {
|
||||||
|
response.Error(c, http.StatusBadRequest, "INVALID_ARGUMENT", "字段标识必须以字母开头,且只能包含字母、数字和下划线")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
item := model.ScenarioField{TenantID: p.TenantID, ScenarioID: scenarioID, FieldKey: input.FieldKey, FieldName: input.FieldName, FieldType: input.FieldType, Required: input.Required, Options: normalizedJSON(input.Options, `[]`), Validation: normalizedJSON(input.Validation, `{}`), SortOrder: input.SortOrder}
|
||||||
|
if err := h.db.Create(&item).Error; err != nil {
|
||||||
|
response.Error(c, http.StatusConflict, "CREATE_FAILED", "字段标识已存在或配置不正确")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = audit.Record(h.db, p, "create", "scenario_field", item.ID, input)
|
||||||
|
response.Created(c, item)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) UpdateField(c *gin.Context) {
|
||||||
|
p, _ := auth.PrincipalFromContext(c)
|
||||||
|
id, ok := idParam(c, "fieldId")
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var input fieldInput
|
||||||
|
if err := c.ShouldBindJSON(&input); err != nil {
|
||||||
|
response.Error(c, http.StatusBadRequest, "INVALID_ARGUMENT", "字段配置不正确")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !fieldKeyPattern.MatchString(input.FieldKey) {
|
||||||
|
response.Error(c, http.StatusBadRequest, "INVALID_ARGUMENT", "字段标识必须以字母开头,且只能包含字母、数字和下划线")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
updates := map[string]interface{}{"field_key": input.FieldKey, "field_name": input.FieldName, "field_type": input.FieldType, "required": input.Required, "options": normalizedJSON(input.Options, `[]`), "validation": normalizedJSON(input.Validation, `{}`), "sort_order": input.SortOrder}
|
||||||
|
result := h.db.Model(&model.ScenarioField{}).Where("id = ? AND tenant_id = ?", id, p.TenantID).Updates(updates)
|
||||||
|
if result.Error != nil || result.RowsAffected == 0 {
|
||||||
|
response.Error(c, http.StatusNotFound, "NOT_FOUND", "字段不存在")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = audit.Record(h.db, p, "update", "scenario_field", id, input)
|
||||||
|
var item model.ScenarioField
|
||||||
|
h.db.Where("id = ? AND tenant_id = ?", id, p.TenantID).First(&item)
|
||||||
|
response.OK(c, item)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) DeleteField(c *gin.Context) {
|
||||||
|
p, _ := auth.PrincipalFromContext(c)
|
||||||
|
id, ok := idParam(c, "fieldId")
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
result := h.db.Where("id = ? AND tenant_id = ?", id, p.TenantID).Delete(&model.ScenarioField{})
|
||||||
|
if result.Error != nil || result.RowsAffected == 0 {
|
||||||
|
response.Error(c, http.StatusNotFound, "NOT_FOUND", "字段不存在")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = audit.Record(h.db, p, "delete", "scenario_field", id, nil)
|
||||||
|
response.OK(c, gin.H{"id": id})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) scenarioExists(tenantID, id uint64) bool {
|
||||||
|
var count int64
|
||||||
|
h.db.Model(&model.Scenario{}).Where("id = ? AND tenant_id = ? AND status <> ?", id, tenantID, "archived").Count(&count)
|
||||||
|
return count == 1
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizedJSON(value json.RawMessage, fallback string) datatypes.JSON {
|
||||||
|
if len(value) == 0 || !json.Valid(value) {
|
||||||
|
return datatypes.JSON([]byte(fallback))
|
||||||
|
}
|
||||||
|
return datatypes.JSON(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
func idParam(c *gin.Context, key string) (uint64, bool) {
|
||||||
|
id, err := strconv.ParseUint(c.Param(key), 10, 64)
|
||||||
|
if err != nil || id == 0 {
|
||||||
|
response.Error(c, http.StatusBadRequest, "INVALID_ID", "资源 ID 不正确")
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
return id, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func notFound(c *gin.Context, err error, message string) {
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
response.Error(c, http.StatusNotFound, "NOT_FOUND", message)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.Error(c, http.StatusInternalServerError, "QUERY_FAILED", "查询失败")
|
||||||
|
}
|
||||||
398
codes/internal/sop/handler.go
Normal file
398
codes/internal/sop/handler.go
Normal file
@@ -0,0 +1,398 @@
|
|||||||
|
package sop
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.iwork-ai.com/xdc/iqudo-top1/internal/audit"
|
||||||
|
"git.iwork-ai.com/xdc/iqudo-top1/internal/auth"
|
||||||
|
"git.iwork-ai.com/xdc/iqudo-top1/internal/model"
|
||||||
|
"git.iwork-ai.com/xdc/iqudo-top1/internal/response"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"gorm.io/datatypes"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Handler struct {
|
||||||
|
db *gorm.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewHandler(db *gorm.DB) *Handler {
|
||||||
|
return &Handler{db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
type createInput struct {
|
||||||
|
Name string `json:"name" binding:"required,max=128"`
|
||||||
|
Description string `json:"description" binding:"max=2000"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type graphInput struct {
|
||||||
|
StartNodeKey string `json:"start_node_key" binding:"required,max=64"`
|
||||||
|
Nodes []nodeInput `json:"nodes" binding:"required,min=1"`
|
||||||
|
Edges []edgeInput `json:"edges"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type nodeInput struct {
|
||||||
|
NodeKey string `json:"node_key" binding:"required,max=64"`
|
||||||
|
Type string `json:"type" binding:"required,max=32"`
|
||||||
|
Title string `json:"title" binding:"required,max=128"`
|
||||||
|
Content string `json:"content" binding:"max=5000"`
|
||||||
|
Config json.RawMessage `json:"config"`
|
||||||
|
PositionX int `json:"position_x"`
|
||||||
|
PositionY int `json:"position_y"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type edgeInput struct {
|
||||||
|
SourceNodeKey string `json:"source_node_key" binding:"required,max=64"`
|
||||||
|
TargetNodeKey string `json:"target_node_key" binding:"required,max=64"`
|
||||||
|
Condition json.RawMessage `json:"condition"`
|
||||||
|
Priority int `json:"priority"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) List(c *gin.Context) {
|
||||||
|
p, _ := auth.PrincipalFromContext(c)
|
||||||
|
var items []model.SOP
|
||||||
|
query := h.db.Where("tenant_id = ? AND status <> ?", p.TenantID, "archived")
|
||||||
|
scenarioID := c.Query("scenario_id")
|
||||||
|
if scenarioID == "" {
|
||||||
|
scenarioID = c.Param("id")
|
||||||
|
}
|
||||||
|
if scenarioID != "" {
|
||||||
|
query = query.Where("scenario_id = ?", scenarioID)
|
||||||
|
}
|
||||||
|
if err := query.Order("updated_at DESC").Find(&items).Error; err != nil {
|
||||||
|
response.Error(c, http.StatusInternalServerError, "QUERY_FAILED", "查询 SOP 失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.OK(c, gin.H{"items": items, "total": len(items)})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) Create(c *gin.Context) {
|
||||||
|
p, _ := auth.PrincipalFromContext(c)
|
||||||
|
scenarioID, ok := parseID(c, "id")
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var scenario model.Scenario
|
||||||
|
if err := h.db.Where("id = ? AND tenant_id = ?", scenarioID, p.TenantID).First(&scenario).Error; err != nil {
|
||||||
|
response.Error(c, http.StatusNotFound, "NOT_FOUND", "场景不存在")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var input createInput
|
||||||
|
if err := c.ShouldBindJSON(&input); err != nil {
|
||||||
|
response.Error(c, http.StatusBadRequest, "INVALID_ARGUMENT", "SOP 信息不完整")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var item model.SOP
|
||||||
|
var version model.SOPVersion
|
||||||
|
err := h.db.Transaction(func(tx *gorm.DB) error {
|
||||||
|
item = model.SOP{TenantID: p.TenantID, ScenarioID: scenarioID, Name: input.Name, Description: input.Description, Status: "draft", CreatedBy: p.UserID}
|
||||||
|
if err := tx.Create(&item).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
version = model.SOPVersion{TenantID: p.TenantID, SOPID: item.ID, Version: 1, Status: "draft", StartNodeKey: "start", CreatedBy: p.UserID}
|
||||||
|
if err := tx.Create(&version).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
nodes := []model.SOPNode{
|
||||||
|
{TenantID: p.TenantID, SOPVersionID: version.ID, NodeKey: "start", Type: "start", Title: "开始", Content: "", Config: datatypes.JSON([]byte(`{}`)), PositionX: 0, PositionY: 0},
|
||||||
|
{TenantID: p.TenantID, SOPVersionID: version.ID, NodeKey: "opening", Type: "message", Title: "开场", Content: "您好,我先了解一下具体情况。", Config: datatypes.JSON([]byte(`{}`)), PositionX: 0, PositionY: 120},
|
||||||
|
{TenantID: p.TenantID, SOPVersionID: version.ID, NodeKey: "finish", Type: "finish", Title: "结束", Content: "本次沟通已完成。", Config: datatypes.JSON([]byte(`{}`)), PositionX: 0, PositionY: 240},
|
||||||
|
}
|
||||||
|
if err := tx.Create(&nodes).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
edges := []model.SOPEdge{
|
||||||
|
{TenantID: p.TenantID, SOPVersionID: version.ID, SourceNodeKey: "start", TargetNodeKey: "opening", Condition: datatypes.JSON([]byte(`{}`)), Priority: 0},
|
||||||
|
{TenantID: p.TenantID, SOPVersionID: version.ID, SourceNodeKey: "opening", TargetNodeKey: "finish", Condition: datatypes.JSON([]byte(`{}`)), Priority: 0},
|
||||||
|
}
|
||||||
|
return tx.Create(&edges).Error
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
response.Error(c, http.StatusInternalServerError, "CREATE_FAILED", "创建 SOP 失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = audit.Record(h.db, p, "create", "sop", item.ID, input)
|
||||||
|
response.Created(c, gin.H{"sop": item, "version": version})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) Get(c *gin.Context) {
|
||||||
|
p, _ := auth.PrincipalFromContext(c)
|
||||||
|
id, ok := parseID(c, "id")
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
item, version, nodes, edges, err := h.loadLatest(id, p.TenantID)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
response.Error(c, http.StatusNotFound, "NOT_FOUND", "SOP 不存在")
|
||||||
|
} else {
|
||||||
|
response.Error(c, http.StatusInternalServerError, "QUERY_FAILED", "查询 SOP 失败")
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.OK(c, gin.H{"sop": item, "version": version, "nodes": nodes, "edges": edges})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) SaveGraph(c *gin.Context) {
|
||||||
|
p, _ := auth.PrincipalFromContext(c)
|
||||||
|
id, ok := parseID(c, "id")
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var input graphInput
|
||||||
|
if err := c.ShouldBindJSON(&input); err != nil {
|
||||||
|
response.Error(c, http.StatusBadRequest, "INVALID_ARGUMENT", "流程配置不完整")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var version model.SOPVersion
|
||||||
|
if err := h.db.Where("sop_id = ? AND tenant_id = ? AND status = ?", id, p.TenantID, "draft").Order("version DESC").First(&version).Error; err != nil {
|
||||||
|
response.Error(c, http.StatusConflict, "NO_DRAFT_VERSION", "没有可编辑的草稿版本")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
nodes, edges := toModels(p.TenantID, version.ID, input)
|
||||||
|
problems := ValidateGraph(input.StartNodeKey, nodes, edges)
|
||||||
|
if len(problems) > 0 {
|
||||||
|
response.Error(c, http.StatusUnprocessableEntity, "INVALID_GRAPH", problems[0])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
err := h.db.Transaction(func(tx *gorm.DB) error {
|
||||||
|
if err := tx.Where("sop_version_id = ?", version.ID).Delete(&model.SOPEdge{}).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := tx.Where("sop_version_id = ?", version.ID).Delete(&model.SOPNode{}).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := tx.Create(&nodes).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if len(edges) > 0 {
|
||||||
|
if err := tx.Create(&edges).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return tx.Model(&version).Update("start_node_key", input.StartNodeKey).Error
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
response.Error(c, http.StatusInternalServerError, "SAVE_FAILED", "保存流程失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = audit.Record(h.db, p, "save_graph", "sop", id, gin.H{"version_id": version.ID})
|
||||||
|
h.Get(c)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) Validate(c *gin.Context) {
|
||||||
|
p, _ := auth.PrincipalFromContext(c)
|
||||||
|
id, ok := parseID(c, "id")
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_, version, nodes, edges, err := h.loadLatest(id, p.TenantID)
|
||||||
|
if err != nil {
|
||||||
|
response.Error(c, http.StatusNotFound, "NOT_FOUND", "SOP 不存在")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
problems := ValidateGraph(version.StartNodeKey, nodes, edges)
|
||||||
|
response.OK(c, gin.H{"valid": len(problems) == 0, "problems": problems})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) SubmitReview(c *gin.Context) {
|
||||||
|
p, _ := auth.PrincipalFromContext(c)
|
||||||
|
id, ok := parseID(c, "id")
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
item, version, nodes, edges, err := h.loadLatest(id, p.TenantID)
|
||||||
|
if err != nil || version.Status != "draft" {
|
||||||
|
response.Error(c, http.StatusConflict, "NO_DRAFT_VERSION", "没有可提交审核的草稿版本")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
problems := ValidateGraph(version.StartNodeKey, nodes, edges)
|
||||||
|
if len(problems) > 0 {
|
||||||
|
response.Error(c, http.StatusUnprocessableEntity, "INVALID_GRAPH", problems[0])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
err = h.db.Transaction(func(tx *gorm.DB) error {
|
||||||
|
if err := tx.Model(&version).Update("status", "reviewing").Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return tx.Model(&item).Update("status", "reviewing").Error
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
response.Error(c, http.StatusInternalServerError, "SUBMIT_REVIEW_FAILED", "提交审核失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = audit.Record(h.db, p, "submit_review", "sop", id, gin.H{"version": version.Version})
|
||||||
|
response.OK(c, gin.H{"id": id, "version": version.Version, "status": "reviewing"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) Publish(c *gin.Context) {
|
||||||
|
p, _ := auth.PrincipalFromContext(c)
|
||||||
|
id, ok := parseID(c, "id")
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
item, version, nodes, edges, err := h.loadLatest(id, p.TenantID)
|
||||||
|
if err != nil || (version.Status != "draft" && version.Status != "reviewing") {
|
||||||
|
response.Error(c, http.StatusConflict, "NO_REVIEW_VERSION", "没有可发布的审核版本")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
problems := ValidateGraph(version.StartNodeKey, nodes, edges)
|
||||||
|
if len(problems) > 0 {
|
||||||
|
response.Error(c, http.StatusUnprocessableEntity, "INVALID_GRAPH", problems[0])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
now := time.Now()
|
||||||
|
err = h.db.Transaction(func(tx *gorm.DB) error {
|
||||||
|
if err := tx.Model(&model.SOPVersion{}).Where("sop_id = ? AND tenant_id = ? AND status = ?", id, p.TenantID, "published").Update("status", "superseded").Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := tx.Model(&version).Updates(map[string]interface{}{"status": "published", "published_at": &now, "reviewed_by": p.UserID}).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := tx.Model(&item).Update("status", "published").Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return tx.Model(&model.Scenario{}).Where("id = ? AND tenant_id = ?", item.ScenarioID, p.TenantID).Update("status", "active").Error
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
response.Error(c, http.StatusInternalServerError, "PUBLISH_FAILED", "发布 SOP 失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = audit.Record(h.db, p, "publish", "sop", id, gin.H{"version": version.Version})
|
||||||
|
response.OK(c, gin.H{"id": id, "version": version.Version, "published_at": now})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) Offline(c *gin.Context) {
|
||||||
|
p, _ := auth.PrincipalFromContext(c)
|
||||||
|
id, ok := parseID(c, "id")
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var item model.SOP
|
||||||
|
if err := h.db.Where("id = ? AND tenant_id = ? AND status = ?", id, p.TenantID, "published").First(&item).Error; err != nil {
|
||||||
|
response.Error(c, http.StatusNotFound, "NOT_FOUND", "已发布 SOP 不存在")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
err := h.db.Transaction(func(tx *gorm.DB) error {
|
||||||
|
if err := tx.Model(&item).Update("status", "offline").Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := tx.Model(&model.SOPVersion{}).Where("sop_id = ? AND tenant_id = ? AND status = ?", id, p.TenantID, "published").Update("status", "offline").Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var published int64
|
||||||
|
if err := tx.Model(&model.SOP{}).Where("scenario_id = ? AND tenant_id = ? AND status = ?", item.ScenarioID, p.TenantID, "published").Count(&published).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if published == 0 {
|
||||||
|
return tx.Model(&model.Scenario{}).Where("id = ? AND tenant_id = ?", item.ScenarioID, p.TenantID).Update("status", "draft").Error
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
response.Error(c, http.StatusInternalServerError, "OFFLINE_FAILED", "下线 SOP 失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = audit.Record(h.db, p, "offline", "sop", id, nil)
|
||||||
|
response.OK(c, gin.H{"id": id, "status": "offline"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) CreateVersion(c *gin.Context) {
|
||||||
|
p, _ := auth.PrincipalFromContext(c)
|
||||||
|
id, ok := parseID(c, "id")
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var existing int64
|
||||||
|
h.db.Model(&model.SOPVersion{}).Where("sop_id = ? AND tenant_id = ? AND status = ?", id, p.TenantID, "draft").Count(&existing)
|
||||||
|
if existing > 0 {
|
||||||
|
response.Error(c, http.StatusConflict, "DRAFT_EXISTS", "已经存在草稿版本")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_, source, nodes, edges, err := h.loadLatest(id, p.TenantID)
|
||||||
|
if err != nil {
|
||||||
|
response.Error(c, http.StatusNotFound, "NOT_FOUND", "SOP 不存在")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var version model.SOPVersion
|
||||||
|
err = h.db.Transaction(func(tx *gorm.DB) error {
|
||||||
|
version = model.SOPVersion{TenantID: p.TenantID, SOPID: id, Version: source.Version + 1, Status: "draft", StartNodeKey: source.StartNodeKey, CreatedBy: p.UserID}
|
||||||
|
if err := tx.Create(&version).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for i := range nodes {
|
||||||
|
nodes[i].Base = model.Base{}
|
||||||
|
nodes[i].SOPVersionID = version.ID
|
||||||
|
}
|
||||||
|
for i := range edges {
|
||||||
|
edges[i].Base = model.Base{}
|
||||||
|
edges[i].SOPVersionID = version.ID
|
||||||
|
}
|
||||||
|
if err := tx.Create(&nodes).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if len(edges) > 0 {
|
||||||
|
return tx.Create(&edges).Error
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
response.Error(c, http.StatusInternalServerError, "CREATE_VERSION_FAILED", "创建草稿版本失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = audit.Record(h.db, p, "create_version", "sop", id, gin.H{"version": version.Version})
|
||||||
|
response.Created(c, version)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) loadLatest(sopID, tenantID uint64) (model.SOP, model.SOPVersion, []model.SOPNode, []model.SOPEdge, error) {
|
||||||
|
var item model.SOP
|
||||||
|
if err := h.db.Where("id = ? AND tenant_id = ?", sopID, tenantID).First(&item).Error; err != nil {
|
||||||
|
return item, model.SOPVersion{}, nil, nil, err
|
||||||
|
}
|
||||||
|
var version model.SOPVersion
|
||||||
|
if err := h.db.Where("sop_id = ? AND tenant_id = ?", sopID, tenantID).Order("version DESC").First(&version).Error; err != nil {
|
||||||
|
return item, version, nil, nil, err
|
||||||
|
}
|
||||||
|
var nodes []model.SOPNode
|
||||||
|
var edges []model.SOPEdge
|
||||||
|
if err := h.db.Where("sop_version_id = ? AND tenant_id = ?", version.ID, tenantID).Order("position_y, id").Find(&nodes).Error; err != nil {
|
||||||
|
return item, version, nil, nil, err
|
||||||
|
}
|
||||||
|
if err := h.db.Where("sop_version_id = ? AND tenant_id = ?", version.ID, tenantID).Order("priority, id").Find(&edges).Error; err != nil {
|
||||||
|
return item, version, nil, nil, err
|
||||||
|
}
|
||||||
|
return item, version, nodes, edges, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func toModels(tenantID, versionID uint64, input graphInput) ([]model.SOPNode, []model.SOPEdge) {
|
||||||
|
nodes := make([]model.SOPNode, 0, len(input.Nodes))
|
||||||
|
for _, item := range input.Nodes {
|
||||||
|
config := item.Config
|
||||||
|
if len(config) == 0 || !json.Valid(config) {
|
||||||
|
config = json.RawMessage(`{}`)
|
||||||
|
}
|
||||||
|
nodes = append(nodes, model.SOPNode{TenantID: tenantID, SOPVersionID: versionID, NodeKey: item.NodeKey, Type: item.Type, Title: item.Title, Content: item.Content, Config: datatypes.JSON(config), PositionX: item.PositionX, PositionY: item.PositionY})
|
||||||
|
}
|
||||||
|
edges := make([]model.SOPEdge, 0, len(input.Edges))
|
||||||
|
for _, item := range input.Edges {
|
||||||
|
condition := item.Condition
|
||||||
|
if len(condition) == 0 || !json.Valid(condition) {
|
||||||
|
condition = json.RawMessage(`{}`)
|
||||||
|
}
|
||||||
|
edges = append(edges, model.SOPEdge{TenantID: tenantID, SOPVersionID: versionID, SourceNodeKey: item.SourceNodeKey, TargetNodeKey: item.TargetNodeKey, Condition: datatypes.JSON(condition), Priority: item.Priority})
|
||||||
|
}
|
||||||
|
return nodes, edges
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseID(c *gin.Context, name string) (uint64, bool) {
|
||||||
|
id, err := strconv.ParseUint(c.Param(name), 10, 64)
|
||||||
|
if err != nil || id == 0 {
|
||||||
|
response.Error(c, http.StatusBadRequest, "INVALID_ID", "资源 ID 不正确")
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
return id, true
|
||||||
|
}
|
||||||
90
codes/internal/sop/validator.go
Normal file
90
codes/internal/sop/validator.go
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
package sop
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"git.iwork-ai.com/xdc/iqudo-top1/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
var allowedNodeTypes = map[string]bool{"start": true, "message": true, "question": true, "form": true, "choice": true, "condition": true, "knowledge": true, "escalate": true, "finish": true}
|
||||||
|
|
||||||
|
func ValidateGraph(startNodeKey string, nodes []model.SOPNode, edges []model.SOPEdge) []string {
|
||||||
|
var problems []string
|
||||||
|
if startNodeKey == "" {
|
||||||
|
problems = append(problems, "未设置开始节点")
|
||||||
|
}
|
||||||
|
nodeMap := make(map[string]model.SOPNode, len(nodes))
|
||||||
|
startCount := 0
|
||||||
|
finishCount := 0
|
||||||
|
for _, node := range nodes {
|
||||||
|
if node.NodeKey == "" {
|
||||||
|
problems = append(problems, "存在没有标识的节点")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, exists := nodeMap[node.NodeKey]; exists {
|
||||||
|
problems = append(problems, fmt.Sprintf("节点标识重复:%s", node.NodeKey))
|
||||||
|
}
|
||||||
|
nodeMap[node.NodeKey] = node
|
||||||
|
if !allowedNodeTypes[node.Type] {
|
||||||
|
problems = append(problems, fmt.Sprintf("节点 %s 类型不支持", node.Title))
|
||||||
|
}
|
||||||
|
if node.Type == "start" {
|
||||||
|
startCount++
|
||||||
|
}
|
||||||
|
if node.Type == "finish" || node.Type == "escalate" {
|
||||||
|
finishCount++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if startCount != 1 {
|
||||||
|
problems = append(problems, "流程必须且只能包含一个开始节点")
|
||||||
|
}
|
||||||
|
if finishCount == 0 {
|
||||||
|
problems = append(problems, "流程至少需要一个结束或转人工节点")
|
||||||
|
}
|
||||||
|
if _, exists := nodeMap[startNodeKey]; !exists && startNodeKey != "" {
|
||||||
|
problems = append(problems, "开始节点不存在")
|
||||||
|
}
|
||||||
|
|
||||||
|
adjacency := make(map[string][]string)
|
||||||
|
outgoing := make(map[string]int)
|
||||||
|
for _, edge := range edges {
|
||||||
|
if _, exists := nodeMap[edge.SourceNodeKey]; !exists {
|
||||||
|
problems = append(problems, fmt.Sprintf("连线起点不存在:%s", edge.SourceNodeKey))
|
||||||
|
}
|
||||||
|
if _, exists := nodeMap[edge.TargetNodeKey]; !exists {
|
||||||
|
problems = append(problems, fmt.Sprintf("连线终点不存在:%s", edge.TargetNodeKey))
|
||||||
|
}
|
||||||
|
if len(edge.Condition) > 0 && !json.Valid(edge.Condition) {
|
||||||
|
problems = append(problems, fmt.Sprintf("连线 %s -> %s 的条件不是有效 JSON", edge.SourceNodeKey, edge.TargetNodeKey))
|
||||||
|
}
|
||||||
|
adjacency[edge.SourceNodeKey] = append(adjacency[edge.SourceNodeKey], edge.TargetNodeKey)
|
||||||
|
outgoing[edge.SourceNodeKey]++
|
||||||
|
}
|
||||||
|
for _, node := range nodes {
|
||||||
|
if node.Type != "finish" && node.Type != "escalate" && outgoing[node.NodeKey] == 0 {
|
||||||
|
problems = append(problems, fmt.Sprintf("节点“%s”没有下一步", node.Title))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
visited := map[string]bool{}
|
||||||
|
var walk func(string)
|
||||||
|
walk = func(key string) {
|
||||||
|
if visited[key] {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
visited[key] = true
|
||||||
|
for _, next := range adjacency[key] {
|
||||||
|
walk(next)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if startNodeKey != "" {
|
||||||
|
walk(startNodeKey)
|
||||||
|
}
|
||||||
|
for key, node := range nodeMap {
|
||||||
|
if !visited[key] {
|
||||||
|
problems = append(problems, fmt.Sprintf("节点“%s”无法从开始节点到达", node.Title))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return problems
|
||||||
|
}
|
||||||
81
codes/main.go
Normal file
81
codes/main.go
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"embed"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"io/fs"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"syscall"
|
||||||
|
|
||||||
|
"git.iwork-ai.com/xdc/iqudo-top1/internal/auth"
|
||||||
|
"git.iwork-ai.com/xdc/iqudo-top1/internal/config"
|
||||||
|
"git.iwork-ai.com/xdc/iqudo-top1/internal/database"
|
||||||
|
"git.iwork-ai.com/xdc/iqudo-top1/internal/httpserver"
|
||||||
|
"git.iwork-ai.com/xdc/iqudo-top1/internal/logger"
|
||||||
|
"git.iwork-ai.com/xdc/iqudo-top1/internal/migration"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:embed migrations/*.sql
|
||||||
|
var migrationFiles embed.FS
|
||||||
|
|
||||||
|
//go:embed web/dist/*
|
||||||
|
var frontendFiles embed.FS
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
environment := flag.String("env", "", "runtime environment: development, test, prod")
|
||||||
|
configDir := flag.String("config-dir", "configs", "configuration directory")
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
|
cfg, err := config.Load(config.LoadOptions{Environment: *environment, ConfigDir: *configDir})
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "load configuration: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
log, err := logger.New(cfg.App.Env, cfg.App.Name)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "create logger: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
defer log.Sync()
|
||||||
|
|
||||||
|
db, err := database.Open(cfg.Database, log)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal("connect database", zap.Error(err))
|
||||||
|
}
|
||||||
|
if err := migration.Run(db, migrationFiles); err != nil {
|
||||||
|
log.Fatal("run database migrations", zap.Error(err))
|
||||||
|
}
|
||||||
|
if err := auth.Seed(db, cfg.Seed); err != nil {
|
||||||
|
log.Fatal("seed administrator", zap.Error(err))
|
||||||
|
}
|
||||||
|
|
||||||
|
frontend, err := fs.Sub(frontendFiles, "web/dist")
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal("load embedded frontend", zap.Error(err))
|
||||||
|
}
|
||||||
|
authService := auth.NewService(db, cfg.Auth)
|
||||||
|
handler := httpserver.New(db, authService, frontend, log, cfg.App.Env)
|
||||||
|
server := &http.Server{Addr: cfg.Server.Address(), Handler: handler, ReadTimeout: cfg.Server.ReadTimeout, WriteTimeout: cfg.Server.WriteTimeout}
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
log.Info("server started", zap.String("address", cfg.Server.Address()))
|
||||||
|
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||||
|
log.Fatal("server stopped unexpectedly", zap.Error(err))
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
stop := make(chan os.Signal, 1)
|
||||||
|
signal.Notify(stop, syscall.SIGINT, syscall.SIGTERM)
|
||||||
|
<-stop
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), cfg.Server.ShutdownTimeout)
|
||||||
|
defer cancel()
|
||||||
|
if err := server.Shutdown(ctx); err != nil {
|
||||||
|
log.Error("graceful shutdown failed", zap.Error(err))
|
||||||
|
}
|
||||||
|
log.Info("server stopped")
|
||||||
|
}
|
||||||
17
codes/migrations/000001_init.down.sql
Normal file
17
codes/migrations/000001_init.down.sql
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
DROP TABLE IF EXISTS refresh_tokens;
|
||||||
|
DROP TABLE IF EXISTS audit_logs;
|
||||||
|
DROP TABLE IF EXISTS sop_feedback;
|
||||||
|
DROP TABLE IF EXISTS sop_run_events;
|
||||||
|
DROP TABLE IF EXISTS sop_runs;
|
||||||
|
DROP TABLE IF EXISTS knowledge_card_versions;
|
||||||
|
DROP TABLE IF EXISTS knowledge_cards;
|
||||||
|
DROP TABLE IF EXISTS sop_edges;
|
||||||
|
DROP TABLE IF EXISTS sop_nodes;
|
||||||
|
DROP TABLE IF EXISTS sop_versions;
|
||||||
|
DROP TABLE IF EXISTS sops;
|
||||||
|
DROP TABLE IF EXISTS scenario_fields;
|
||||||
|
DROP TABLE IF EXISTS scenarios;
|
||||||
|
DROP TABLE IF EXISTS tenant_members;
|
||||||
|
DROP TABLE IF EXISTS roles;
|
||||||
|
DROP TABLE IF EXISTS users;
|
||||||
|
DROP TABLE IF EXISTS tenants;
|
||||||
258
codes/migrations/000001_init.up.sql
Normal file
258
codes/migrations/000001_init.up.sql
Normal file
@@ -0,0 +1,258 @@
|
|||||||
|
CREATE TABLE tenants (
|
||||||
|
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
name VARCHAR(128) NOT NULL,
|
||||||
|
slug VARCHAR(64) NOT NULL,
|
||||||
|
status VARCHAR(24) NOT NULL DEFAULT 'active',
|
||||||
|
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
|
||||||
|
PRIMARY KEY (id), UNIQUE KEY uk_tenants_slug (slug)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
CREATE TABLE users (
|
||||||
|
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
username VARCHAR(64) NOT NULL,
|
||||||
|
password_hash VARCHAR(255) NOT NULL,
|
||||||
|
display_name VARCHAR(128) NOT NULL,
|
||||||
|
status VARCHAR(24) NOT NULL DEFAULT 'active',
|
||||||
|
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
|
||||||
|
PRIMARY KEY (id), UNIQUE KEY uk_users_username (username)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
CREATE TABLE roles (
|
||||||
|
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
tenant_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
name VARCHAR(64) NOT NULL,
|
||||||
|
code VARCHAR(64) NOT NULL,
|
||||||
|
permissions JSON NOT NULL,
|
||||||
|
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
|
||||||
|
PRIMARY KEY (id), UNIQUE KEY uk_roles_tenant_code (tenant_id, code),
|
||||||
|
CONSTRAINT fk_roles_tenant FOREIGN KEY (tenant_id) REFERENCES tenants(id)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
CREATE TABLE tenant_members (
|
||||||
|
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
tenant_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
user_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
role_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
status VARCHAR(24) NOT NULL DEFAULT 'active',
|
||||||
|
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
|
||||||
|
PRIMARY KEY (id), UNIQUE KEY uk_tenant_members (tenant_id, user_id),
|
||||||
|
CONSTRAINT fk_members_tenant FOREIGN KEY (tenant_id) REFERENCES tenants(id),
|
||||||
|
CONSTRAINT fk_members_user FOREIGN KEY (user_id) REFERENCES users(id),
|
||||||
|
CONSTRAINT fk_members_role FOREIGN KEY (role_id) REFERENCES roles(id)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
CREATE TABLE scenarios (
|
||||||
|
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
tenant_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
name VARCHAR(128) NOT NULL,
|
||||||
|
industry VARCHAR(64) NOT NULL,
|
||||||
|
role_name VARCHAR(64) NOT NULL,
|
||||||
|
goal TEXT NOT NULL,
|
||||||
|
trigger_text TEXT NOT NULL,
|
||||||
|
visibility VARCHAR(24) NOT NULL DEFAULT 'tenant',
|
||||||
|
status VARCHAR(24) NOT NULL DEFAULT 'draft',
|
||||||
|
created_by BIGINT UNSIGNED NOT NULL,
|
||||||
|
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
|
||||||
|
PRIMARY KEY (id), KEY idx_scenarios_tenant_status (tenant_id, status),
|
||||||
|
CONSTRAINT fk_scenarios_tenant FOREIGN KEY (tenant_id) REFERENCES tenants(id),
|
||||||
|
CONSTRAINT fk_scenarios_creator FOREIGN KEY (created_by) REFERENCES users(id)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
CREATE TABLE scenario_fields (
|
||||||
|
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
tenant_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
scenario_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
field_key VARCHAR(64) NOT NULL,
|
||||||
|
field_name VARCHAR(128) NOT NULL,
|
||||||
|
field_type VARCHAR(32) NOT NULL,
|
||||||
|
required TINYINT(1) NOT NULL DEFAULT 0,
|
||||||
|
options JSON NOT NULL,
|
||||||
|
validation JSON NOT NULL,
|
||||||
|
sort_order INT NOT NULL DEFAULT 0,
|
||||||
|
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
|
||||||
|
PRIMARY KEY (id), UNIQUE KEY uk_scenario_fields_key (scenario_id, field_key),
|
||||||
|
CONSTRAINT fk_fields_tenant FOREIGN KEY (tenant_id) REFERENCES tenants(id),
|
||||||
|
CONSTRAINT fk_fields_scenario FOREIGN KEY (scenario_id) REFERENCES scenarios(id) ON DELETE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
CREATE TABLE sops (
|
||||||
|
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
tenant_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
scenario_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
name VARCHAR(128) NOT NULL,
|
||||||
|
description TEXT NOT NULL,
|
||||||
|
status VARCHAR(24) NOT NULL DEFAULT 'draft',
|
||||||
|
created_by BIGINT UNSIGNED NOT NULL,
|
||||||
|
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
|
||||||
|
PRIMARY KEY (id), KEY idx_sops_scenario (tenant_id, scenario_id),
|
||||||
|
CONSTRAINT fk_sops_tenant FOREIGN KEY (tenant_id) REFERENCES tenants(id),
|
||||||
|
CONSTRAINT fk_sops_scenario FOREIGN KEY (scenario_id) REFERENCES scenarios(id),
|
||||||
|
CONSTRAINT fk_sops_creator FOREIGN KEY (created_by) REFERENCES users(id)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
CREATE TABLE sop_versions (
|
||||||
|
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
tenant_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
sop_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
version INT NOT NULL,
|
||||||
|
status VARCHAR(24) NOT NULL DEFAULT 'draft',
|
||||||
|
start_node_key VARCHAR(64) NOT NULL DEFAULT '',
|
||||||
|
published_at DATETIME(3) NULL,
|
||||||
|
created_by BIGINT UNSIGNED NOT NULL,
|
||||||
|
reviewed_by BIGINT UNSIGNED NULL,
|
||||||
|
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
|
||||||
|
PRIMARY KEY (id), UNIQUE KEY uk_sop_versions (sop_id, version),
|
||||||
|
CONSTRAINT fk_versions_tenant FOREIGN KEY (tenant_id) REFERENCES tenants(id),
|
||||||
|
CONSTRAINT fk_versions_sop FOREIGN KEY (sop_id) REFERENCES sops(id) ON DELETE CASCADE,
|
||||||
|
CONSTRAINT fk_versions_creator FOREIGN KEY (created_by) REFERENCES users(id),
|
||||||
|
CONSTRAINT fk_versions_reviewer FOREIGN KEY (reviewed_by) REFERENCES users(id)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
CREATE TABLE sop_nodes (
|
||||||
|
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
tenant_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
sop_version_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
node_key VARCHAR(64) NOT NULL,
|
||||||
|
type VARCHAR(32) NOT NULL,
|
||||||
|
title VARCHAR(128) NOT NULL,
|
||||||
|
content TEXT NOT NULL,
|
||||||
|
config JSON NOT NULL,
|
||||||
|
position_x INT NOT NULL DEFAULT 0,
|
||||||
|
position_y INT NOT NULL DEFAULT 0,
|
||||||
|
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
|
||||||
|
PRIMARY KEY (id), UNIQUE KEY uk_sop_nodes_key (sop_version_id, node_key),
|
||||||
|
CONSTRAINT fk_nodes_tenant FOREIGN KEY (tenant_id) REFERENCES tenants(id),
|
||||||
|
CONSTRAINT fk_nodes_version FOREIGN KEY (sop_version_id) REFERENCES sop_versions(id) ON DELETE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
CREATE TABLE sop_edges (
|
||||||
|
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
tenant_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
sop_version_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
source_node_key VARCHAR(64) NOT NULL,
|
||||||
|
target_node_key VARCHAR(64) NOT NULL,
|
||||||
|
`condition` JSON NOT NULL,
|
||||||
|
priority INT NOT NULL DEFAULT 0,
|
||||||
|
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
|
||||||
|
PRIMARY KEY (id), KEY idx_sop_edges_source (sop_version_id, source_node_key, priority),
|
||||||
|
CONSTRAINT fk_edges_tenant FOREIGN KEY (tenant_id) REFERENCES tenants(id),
|
||||||
|
CONSTRAINT fk_edges_version FOREIGN KEY (sop_version_id) REFERENCES sop_versions(id) ON DELETE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
CREATE TABLE knowledge_cards (
|
||||||
|
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
tenant_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
scenario_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
title VARCHAR(128) NOT NULL,
|
||||||
|
status VARCHAR(24) NOT NULL DEFAULT 'draft',
|
||||||
|
created_by BIGINT UNSIGNED NOT NULL,
|
||||||
|
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
|
||||||
|
PRIMARY KEY (id), KEY idx_cards_scenario (tenant_id, scenario_id),
|
||||||
|
CONSTRAINT fk_cards_tenant FOREIGN KEY (tenant_id) REFERENCES tenants(id),
|
||||||
|
CONSTRAINT fk_cards_scenario FOREIGN KEY (scenario_id) REFERENCES scenarios(id),
|
||||||
|
CONSTRAINT fk_cards_creator FOREIGN KEY (created_by) REFERENCES users(id)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
CREATE TABLE knowledge_card_versions (
|
||||||
|
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
tenant_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
knowledge_card_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
version INT NOT NULL,
|
||||||
|
content JSON NOT NULL,
|
||||||
|
status VARCHAR(24) NOT NULL DEFAULT 'draft',
|
||||||
|
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
|
||||||
|
PRIMARY KEY (id), UNIQUE KEY uk_card_versions (knowledge_card_id, version),
|
||||||
|
CONSTRAINT fk_card_versions_tenant FOREIGN KEY (tenant_id) REFERENCES tenants(id),
|
||||||
|
CONSTRAINT fk_card_versions_card FOREIGN KEY (knowledge_card_id) REFERENCES knowledge_cards(id) ON DELETE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
CREATE TABLE sop_runs (
|
||||||
|
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
tenant_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
sop_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
sop_version_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
operator_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
current_node_key VARCHAR(64) NOT NULL,
|
||||||
|
status VARCHAR(24) NOT NULL DEFAULT 'running',
|
||||||
|
answers JSON NOT NULL,
|
||||||
|
result VARCHAR(64) NOT NULL DEFAULT '',
|
||||||
|
started_at DATETIME(3) NOT NULL,
|
||||||
|
completed_at DATETIME(3) NULL,
|
||||||
|
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
|
||||||
|
PRIMARY KEY (id), KEY idx_runs_tenant_status (tenant_id, status),
|
||||||
|
CONSTRAINT fk_runs_tenant FOREIGN KEY (tenant_id) REFERENCES tenants(id),
|
||||||
|
CONSTRAINT fk_runs_sop FOREIGN KEY (sop_id) REFERENCES sops(id),
|
||||||
|
CONSTRAINT fk_runs_version FOREIGN KEY (sop_version_id) REFERENCES sop_versions(id),
|
||||||
|
CONSTRAINT fk_runs_operator FOREIGN KEY (operator_id) REFERENCES users(id)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
CREATE TABLE sop_run_events (
|
||||||
|
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
tenant_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
run_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
node_key VARCHAR(64) NOT NULL,
|
||||||
|
action VARCHAR(64) NOT NULL,
|
||||||
|
payload JSON NOT NULL,
|
||||||
|
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
|
||||||
|
PRIMARY KEY (id), KEY idx_run_events (run_id, created_at),
|
||||||
|
CONSTRAINT fk_run_events_tenant FOREIGN KEY (tenant_id) REFERENCES tenants(id),
|
||||||
|
CONSTRAINT fk_run_events_run FOREIGN KEY (run_id) REFERENCES sop_runs(id) ON DELETE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
CREATE TABLE sop_feedback (
|
||||||
|
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
tenant_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
run_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
user_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
score INT NOT NULL,
|
||||||
|
comment TEXT NOT NULL,
|
||||||
|
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
|
||||||
|
PRIMARY KEY (id), UNIQUE KEY uk_feedback_run_user (run_id, user_id),
|
||||||
|
CONSTRAINT fk_feedback_tenant FOREIGN KEY (tenant_id) REFERENCES tenants(id),
|
||||||
|
CONSTRAINT fk_feedback_run FOREIGN KEY (run_id) REFERENCES sop_runs(id) ON DELETE CASCADE,
|
||||||
|
CONSTRAINT fk_feedback_user FOREIGN KEY (user_id) REFERENCES users(id)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
CREATE TABLE audit_logs (
|
||||||
|
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
tenant_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
user_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
action VARCHAR(64) NOT NULL,
|
||||||
|
resource VARCHAR(64) NOT NULL,
|
||||||
|
resource_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
payload JSON NOT NULL,
|
||||||
|
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
|
||||||
|
PRIMARY KEY (id), KEY idx_audit_tenant_time (tenant_id, created_at),
|
||||||
|
CONSTRAINT fk_audit_tenant FOREIGN KEY (tenant_id) REFERENCES tenants(id),
|
||||||
|
CONSTRAINT fk_audit_user FOREIGN KEY (user_id) REFERENCES users(id)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
CREATE TABLE refresh_tokens (
|
||||||
|
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
tenant_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
user_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
token_hash VARCHAR(64) NOT NULL,
|
||||||
|
expires_at DATETIME(3) NOT NULL,
|
||||||
|
revoked_at DATETIME(3) NULL,
|
||||||
|
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
|
||||||
|
PRIMARY KEY (id), UNIQUE KEY uk_refresh_token_hash (token_hash),
|
||||||
|
CONSTRAINT fk_refresh_tenant FOREIGN KEY (tenant_id) REFERENCES tenants(id),
|
||||||
|
CONSTRAINT fk_refresh_user FOREIGN KEY (user_id) REFERENCES users(id)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
5
codes/scripts/create-databases.sql
Normal file
5
codes/scripts/create-databases.sql
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
CREATE DATABASE IF NOT EXISTS iqudo_top1
|
||||||
|
CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
CREATE DATABASE IF NOT EXISTS iqudo_top1_test
|
||||||
|
CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
1
codes/web/dist/assets/AppstoreOutlined-BOx6VBGQ.js
vendored
Normal file
1
codes/web/dist/assets/AppstoreOutlined-BOx6VBGQ.js
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
import{F as e,a as t}from"./client-CO11mUW5.js";var n={icon:{tag:`svg`,attrs:{viewBox:`64 64 896 896`,focusable:`false`},children:[{tag:`path`,attrs:{d:`M869 487.8L491.2 159.9c-2.9-2.5-6.6-3.9-10.5-3.9h-88.5c-7.4 0-10.8 9.2-5.2 14l350.2 304H152c-4.4 0-8 3.6-8 8v60c0 4.4 3.6 8 8 8h585.1L386.9 854c-5.6 4.9-2.2 14 5.2 14h91.5c1.9 0 3.8-.7 5.2-2L869 536.2a32.07 32.07 0 000-48.4z`}}]},name:`arrow-right`,theme:`outlined`};function r(e){for(var t=1;t<arguments.length;t++){var n=arguments[t]==null?{}:Object(arguments[t]),r=Object.keys(n);typeof Object.getOwnPropertySymbols==`function`&&(r=r.concat(Object.getOwnPropertySymbols(n).filter(function(e){return Object.getOwnPropertyDescriptor(n,e).enumerable}))),r.forEach(function(t){i(e,t,n[t])})}return e}function i(e,t,n){return t in e?Object.defineProperty(e,t,{value:n,enumerable:!0,configurable:!0,writable:!0}):e[t]=n,e}var a=function(i,a){var o=r({},i,a.attrs);return e(t,r({},o,{icon:n}),null)};a.displayName=`ArrowRightOutlined`,a.inheritAttrs=!1;var o={icon:{tag:`svg`,attrs:{viewBox:`64 64 896 896`,focusable:`false`},children:[{tag:`path`,attrs:{d:`M464 144H160c-8.8 0-16 7.2-16 16v304c0 8.8 7.2 16 16 16h304c8.8 0 16-7.2 16-16V160c0-8.8-7.2-16-16-16zm-52 268H212V212h200v200zm452-268H560c-8.8 0-16 7.2-16 16v304c0 8.8 7.2 16 16 16h304c8.8 0 16-7.2 16-16V160c0-8.8-7.2-16-16-16zm-52 268H612V212h200v200zM464 544H160c-8.8 0-16 7.2-16 16v304c0 8.8 7.2 16 16 16h304c8.8 0 16-7.2 16-16V560c0-8.8-7.2-16-16-16zm-52 268H212V612h200v200zm452-268H560c-8.8 0-16 7.2-16 16v304c0 8.8 7.2 16 16 16h304c8.8 0 16-7.2 16-16V560c0-8.8-7.2-16-16-16zm-52 268H612V612h200v200z`}}]},name:`appstore`,theme:`outlined`};function s(e){for(var t=1;t<arguments.length;t++){var n=arguments[t]==null?{}:Object(arguments[t]),r=Object.keys(n);typeof Object.getOwnPropertySymbols==`function`&&(r=r.concat(Object.getOwnPropertySymbols(n).filter(function(e){return Object.getOwnPropertyDescriptor(n,e).enumerable}))),r.forEach(function(t){c(e,t,n[t])})}return e}function c(e,t,n){return t in e?Object.defineProperty(e,t,{value:n,enumerable:!0,configurable:!0,writable:!0}):e[t]=n,e}var l=function(n,r){var i=s({},n,r.attrs);return e(t,s({},i,{icon:o}),null)};l.displayName=`AppstoreOutlined`,l.inheritAttrs=!1;export{a as n,l as t};
|
||||||
1
codes/web/dist/assets/ArrowLeftOutlined-m6YdiMlD.js
vendored
Normal file
1
codes/web/dist/assets/ArrowLeftOutlined-m6YdiMlD.js
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
import{F as e,a as t}from"./client-CO11mUW5.js";var n={icon:{tag:`svg`,attrs:{viewBox:`64 64 896 896`,focusable:`false`},children:[{tag:`path`,attrs:{d:`M872 474H286.9l350.2-304c5.6-4.9 2.2-14-5.2-14h-88.5c-3.9 0-7.6 1.4-10.5 3.9L155 487.8a31.96 31.96 0 000 48.3L535.1 866c1.5 1.3 3.3 2 5.2 2h91.5c7.4 0 10.8-9.2 5.2-14L286.9 550H872c4.4 0 8-3.6 8-8v-60c0-4.4-3.6-8-8-8z`}}]},name:`arrow-left`,theme:`outlined`};function r(e){for(var t=1;t<arguments.length;t++){var n=arguments[t]==null?{}:Object(arguments[t]),r=Object.keys(n);typeof Object.getOwnPropertySymbols==`function`&&(r=r.concat(Object.getOwnPropertySymbols(n).filter(function(e){return Object.getOwnPropertyDescriptor(n,e).enumerable}))),r.forEach(function(t){i(e,t,n[t])})}return e}function i(e,t,n){return t in e?Object.defineProperty(e,t,{value:n,enumerable:!0,configurable:!0,writable:!0}):e[t]=n,e}var a=function(i,a){var o=r({},i,a.attrs);return e(t,r({},o,{icon:n}),null)};a.displayName=`ArrowLeftOutlined`,a.inheritAttrs=!1;export{a as t};
|
||||||
1
codes/web/dist/assets/BookOutlined-CNUY9qCc.js
vendored
Normal file
1
codes/web/dist/assets/BookOutlined-CNUY9qCc.js
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
import{F as e,a as t}from"./client-CO11mUW5.js";var n={icon:{tag:`svg`,attrs:{viewBox:`64 64 896 896`,focusable:`false`},children:[{tag:`path`,attrs:{d:`M832 64H192c-17.7 0-32 14.3-32 32v832c0 17.7 14.3 32 32 32h640c17.7 0 32-14.3 32-32V96c0-17.7-14.3-32-32-32zm-260 72h96v209.9L621.5 312 572 347.4V136zm220 752H232V136h280v296.9c0 3.3 1 6.6 3 9.3a15.9 15.9 0 0022.3 3.7l83.8-59.9 81.4 59.4c2.7 2 6 3.1 9.4 3.1 8.8 0 16-7.2 16-16V136h64v752z`}}]},name:`book`,theme:`outlined`};function r(e){for(var t=1;t<arguments.length;t++){var n=arguments[t]==null?{}:Object(arguments[t]),r=Object.keys(n);typeof Object.getOwnPropertySymbols==`function`&&(r=r.concat(Object.getOwnPropertySymbols(n).filter(function(e){return Object.getOwnPropertyDescriptor(n,e).enumerable}))),r.forEach(function(t){i(e,t,n[t])})}return e}function i(e,t,n){return t in e?Object.defineProperty(e,t,{value:n,enumerable:!0,configurable:!0,writable:!0}):e[t]=n,e}var a=function(i,a){var o=r({},i,a.attrs);return e(t,r({},o,{icon:n}),null)};a.displayName=`BookOutlined`,a.inheritAttrs=!1;export{a as t};
|
||||||
1
codes/web/dist/assets/DashboardView-DpoQIy_X.js
vendored
Normal file
1
codes/web/dist/assets/DashboardView-DpoQIy_X.js
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
import{Bt as e,F as t,I as n,O as r,P as i,Q as a,Tt as o,Y as s,i as c,j as l,lt as u,r as d,t as f,tt as p,vt as m}from"./client-CO11mUW5.js";import{t as h}from"./PlusOutlined-5Urx-Evx.js";import{n as g,t as _}from"./AppstoreOutlined-BOx6VBGQ.js";import{t as v}from"./PlayCircleOutlined-C2UGH7h6.js";import{h as y}from"./useApi-CROJJdhE-BlzMTLF9.js";var b={class:`page-shell`},x={class:`page-heading`},S={class:`page-actions`},C={class:`metric-strip surface`},w={class:`metric`},T={class:`metric-icon green`},E={class:`metric`},D={class:`metric-icon coral`},O={class:`metric`},k={class:`metric-icon amber`},A={class:`metric`},j={class:`completion`},M={class:`work-grid`},N={class:`surface action-panel`},P=d(n({__name:`DashboardView`,setup(n){let d=y(),P=m(!0),F=m({scenarios:0,published_sops:0,runs:0,completed_runs:0});return s(async()=>{try{F.value=await f.get(`/dashboard/summary`)}finally{P.value=!1}}),(n,s)=>{let f=p(`a-button`),m=p(`a-skeleton`);return a(),l(`div`,b,[r(`div`,x,[s[5]||=r(`div`,null,[r(`h1`,null,`今天从经验开始`),r(`p`,null,`查看知识沉淀和一线执行的最新状态。`)],-1),r(`div`,S,[t(f,{type:`primary`,onClick:s[0]||=e=>o(d).push(`/scenarios`)},{default:u(()=>[t(o(h)),s[4]||=i(`创建场景`,-1)]),_:1})])]),t(m,{loading:P.value,active:``},{default:u(()=>[r(`section`,C,[r(`div`,w,[r(`span`,T,[t(o(_))]),r(`div`,null,[r(`b`,null,e(F.value.scenarios),1),s[6]||=r(`small`,null,`可用场景`,-1)])]),r(`div`,E,[r(`span`,D,[t(o(c))]),r(`div`,null,[r(`b`,null,e(F.value.published_sops),1),s[7]||=r(`small`,null,`已发布 SOP`,-1)])]),r(`div`,O,[r(`span`,k,[t(o(v))]),r(`div`,null,[r(`b`,null,e(F.value.runs),1),s[8]||=r(`small`,null,`累计执行`,-1)])]),r(`div`,A,[r(`span`,j,e(F.value.runs?Math.round(F.value.completed_runs/F.value.runs*100):0)+`%`,1),r(`div`,null,[r(`b`,null,e(F.value.completed_runs),1),s[9]||=r(`small`,null,`完成执行`,-1)])])])]),_:1},8,[`loading`]),r(`section`,M,[r(`div`,N,[s[13]||=r(`div`,{class:`panel-kicker`},`常用入口`,-1),r(`button`,{onClick:s[1]||=e=>o(d).push(`/scenarios`)},[s[10]||=r(`span`,null,[i(`配置新的业务场景`),r(`small`,null,`定义字段、目标和触发条件`)],-1),t(o(g))]),r(`button`,{onClick:s[2]||=e=>o(d).push(`/execute`)},[s[11]||=r(`span`,null,[i(`开始执行已发布 SOP`),r(`small`,null,`根据客户回答逐步推进`)],-1),t(o(g))]),r(`button`,{onClick:s[3]||=e=>o(d).push(`/knowledge`)},[s[12]||=r(`span`,null,[i(`维护审核知识卡`),r(`small`,null,`统一口径与风险提示`)],-1),t(o(g))])]),s[14]||=r(`div`,{class:`surface doctrine-panel`},[r(`div`,{class:`panel-kicker`},`平台原则`),r(`blockquote`,null,`一句好话术不应只被收藏,它应该知道何时出现、下一步去哪,以及是否真的有效。`),r(`div`,{class:`flow-note`},[r(`span`,null,`创建`),r(`i`),r(`span`,null,`发布`),r(`i`),r(`span`,null,`执行`),r(`i`),r(`span`,null,`复盘`)])],-1)])])}}}),[[`__scopeId`,`data-v-233c2a06`]]);export{P as default};
|
||||||
1
codes/web/dist/assets/DashboardView-PPsIN8E9.css
vendored
Normal file
1
codes/web/dist/assets/DashboardView-PPsIN8E9.css
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
.metric-strip[data-v-233c2a06]{grid-template-columns:repeat(4,1fr);display:grid;overflow:hidden}.metric[data-v-233c2a06]{border-right:1px solid var(--line);align-items:center;gap:16px;min-height:116px;padding:22px;display:flex}.metric[data-v-233c2a06]:last-child{border-right:0}.metric-icon[data-v-233c2a06],.completion[data-v-233c2a06]{border-radius:5px;place-items:center;width:42px;height:42px;font-size:19px;display:grid}.metric-icon.green[data-v-233c2a06]{color:#16775b;background:#e5f3ee}.metric-icon.coral[data-v-233c2a06]{color:#c4573f;background:#faece7}.metric-icon.amber[data-v-233c2a06]{color:#a86e1e;background:#f8efdf}.completion[data-v-233c2a06]{color:#202825;background:#e8ecea;font-size:12px;font-weight:800}.metric div[data-v-233c2a06]{flex-direction:column;display:flex}.metric b[data-v-233c2a06]{font-family:Noto Serif SC,serif;font-size:28px;line-height:1}.metric small[data-v-233c2a06]{color:var(--muted);margin-top:8px}.work-grid[data-v-233c2a06]{grid-template-columns:1.05fr .95fr;gap:18px;margin-top:18px;display:grid}.action-panel[data-v-233c2a06],.doctrine-panel[data-v-233c2a06]{padding:22px}.panel-kicker[data-v-233c2a06]{color:#7c8883;text-transform:uppercase;margin-bottom:14px;font-size:11px;font-weight:700}.action-panel button[data-v-233c2a06]{width:100%;color:var(--ink);text-align:left;border:0;border-bottom:1px solid var(--line);cursor:pointer;background:0 0;justify-content:space-between;align-items:center;padding:15px 0;display:flex}.action-panel button[data-v-233c2a06]:last-child{border-bottom:0}.action-panel button[data-v-233c2a06]:hover{color:var(--green)}.action-panel button span[data-v-233c2a06]{flex-direction:column;font-weight:650;display:flex}.action-panel button small[data-v-233c2a06]{color:var(--muted);margin-top:4px;font-weight:400}.doctrine-panel[data-v-233c2a06]{color:#fff;background:#202825;border-color:#202825}.doctrine-panel .panel-kicker[data-v-233c2a06]{color:#89a099}blockquote[data-v-233c2a06]{margin:30px 0 42px;font-family:Noto Serif SC,serif;font-size:22px;line-height:1.65}.flow-note[data-v-233c2a06]{color:#9cafaa;align-items:center;font-size:12px;display:flex}.flow-note i[data-v-233c2a06]{background:#4a5a54;flex:1;height:1px;margin:0 10px}@media (width<=900px){.metric-strip[data-v-233c2a06]{grid-template-columns:repeat(2,1fr)}.metric[data-v-233c2a06]:nth-child(2){border-right:0}.metric[data-v-233c2a06]:nth-child(-n+2){border-bottom:1px solid var(--line)}.work-grid[data-v-233c2a06]{grid-template-columns:1fr}}@media (width<=520px){.metric[data-v-233c2a06]{min-height:100px;padding:16px}.metric b[data-v-233c2a06]{font-size:24px}.metric-icon[data-v-233c2a06],.completion[data-v-233c2a06]{display:none}}
|
||||||
37
codes/web/dist/assets/DeleteOutlined-Dsl9pMnk.js
vendored
Normal file
37
codes/web/dist/assets/DeleteOutlined-Dsl9pMnk.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
codes/web/dist/assets/ExecuteView-DmAbM9s0.js
vendored
Normal file
1
codes/web/dist/assets/ExecuteView-DmAbM9s0.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
codes/web/dist/assets/ExecuteView-bT0HI80x.css
vendored
Normal file
1
codes/web/dist/assets/ExecuteView-bT0HI80x.css
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
.execution-shell[data-v-6a3af49b]{max-width:1220px}.sop-catalog[data-v-6a3af49b]{grid-template-columns:repeat(2,minmax(0,1fr));gap:14px;display:grid}.sop-entry[data-v-6a3af49b]{text-align:left;cursor:pointer;grid-template-columns:40px 1fr 42px;align-items:start;gap:14px;min-height:150px;padding:22px;display:grid}.sop-entry[data-v-6a3af49b]:hover{border-color:#9ac8b8;box-shadow:0 8px 24px #20282512}.entry-seq[data-v-6a3af49b]{color:#8c9893;font-family:monospace;font-size:12px}.sop-entry small[data-v-6a3af49b]{color:var(--green);font-weight:650}.sop-entry h2[data-v-6a3af49b]{margin:8px 0 7px;font-family:Noto Serif SC,serif;font-size:20px}.sop-entry p[data-v-6a3af49b]{color:var(--muted);margin:0;line-height:1.6}.play[data-v-6a3af49b]{color:#fff;background:#202825;border-radius:4px;place-items:center;width:38px;height:38px;font-size:18px;display:grid}.run-workspace[data-v-6a3af49b]{grid-template-columns:270px minmax(0,1fr);gap:18px;display:grid}.run-context[data-v-6a3af49b]{color:#fff;background:#202825;border-radius:6px;align-self:start;padding:24px;position:sticky;top:86px}.run-label[data-v-6a3af49b]{color:#72c9ab;font-size:11px;font-weight:700}.run-context h2[data-v-6a3af49b]{margin:12px 0 8px;font-family:Noto Serif SC,serif}.run-context>p[data-v-6a3af49b]{color:#aebdb7;margin:0 0 28px;line-height:1.6}.context-meta[data-v-6a3af49b]{border-top:1px solid #3a4742;justify-content:space-between;align-items:center;padding:13px 0;display:flex}.context-meta span[data-v-6a3af49b]{color:#9eada7;font-size:12px}.privacy-note[data-v-6a3af49b]{color:#889b93;gap:8px;margin-top:28px;font-size:11px;display:flex}.conversation[data-v-6a3af49b]{min-height:590px;padding:30px 34px}.conversation-progress[data-v-6a3af49b]{color:#66736d;align-items:center;gap:8px;font-size:12px;display:flex}.conversation-progress span[data-v-6a3af49b]{background:#d08736;border-radius:50%;width:8px;height:8px;box-shadow:0 0 0 4px #faefe1}.conversation-progress span.done[data-v-6a3af49b]{background:var(--green);box-shadow:0 0 0 4px #e4f1ec}.node-content[data-v-6a3af49b]{padding:44px 0 26px}.node-content small[data-v-6a3af49b]{color:var(--green);font-size:10px;font-weight:800}.node-content h1[data-v-6a3af49b]{margin:8px 0 22px;font-family:Noto Serif SC,serif;font-size:28px}.node-content blockquote[data-v-6a3af49b]{color:#29342f;border-left:3px solid var(--green);background:#f2f6f4;margin:0;padding:18px 20px;font-size:17px;line-height:1.8}.answer-form[data-v-6a3af49b]{max-width:680px}.choice-group[data-v-6a3af49b]{flex-wrap:wrap;display:flex}.run-actions[data-v-6a3af49b]{border-top:1px solid var(--line);color:var(--muted);justify-content:space-between;align-items:center;gap:16px;margin:24px -34px -30px;padding:18px 34px;font-size:12px;display:flex}.completed-state[data-v-6a3af49b]{text-align:center;padding:40px 0}.completed-state>span[data-v-6a3af49b]{color:var(--green);font-size:50px}.completed-state h3[data-v-6a3af49b]{margin:14px 0 8px;font-family:Noto Serif SC,serif;font-size:24px}.completed-state p[data-v-6a3af49b]{color:var(--muted);margin:0 0 24px}@media (width<=800px){.sop-catalog[data-v-6a3af49b],.run-workspace[data-v-6a3af49b]{grid-template-columns:1fr}.run-context[data-v-6a3af49b]{position:static}.conversation[data-v-6a3af49b]{padding:22px}.run-actions[data-v-6a3af49b]{margin:24px -22px -22px;padding:16px 22px}.run-actions span[data-v-6a3af49b]{display:none}}
|
||||||
1
codes/web/dist/assets/KnowledgeView-W6lPD5mD.js
vendored
Normal file
1
codes/web/dist/assets/KnowledgeView-W6lPD5mD.js
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
import{A as e,Bt as t,D as n,F as r,I as i,O as a,P as o,Q as s,S as c,Tt as l,Y as u,_t as d,et as f,j as p,k as m,lt as h,n as g,r as _,t as v,tt as y,vt as b}from"./client-CO11mUW5.js";import{a as x}from"./config-provider-kwhtQ-D4.js";import{n as S,t as C}from"./DeleteOutlined-Dsl9pMnk.js";import{t as w}from"./PlusOutlined-5Urx-Evx.js";import{t as T}from"./BookOutlined-CNUY9qCc.js";var E={class:`page-shell`},D={class:`page-heading`},O={key:0,class:`knowledge-grid`},k={key:0,class:`risk-note`},A=_(i({__name:`KnowledgeView`,setup(i){let _=b(!1),A=b(!1),j=b([]),M=b([]),N=d({scenario_id:void 0,title:``,content:``,forbidden:``,risk_note:``}),P=n(()=>M.value.map(e=>({value:e.id,label:e.name})));async function F(){_.value=!0;try{let[e,t]=await Promise.all([v.get(`/knowledge-cards`),v.get(`/scenarios`)]);j.value=e.items,M.value=t.items}catch(e){x.error(g(e))}finally{_.value=!1}}async function I(){try{await v.post(`/knowledge-cards`,{scenario_id:N.scenario_id,title:N.title,content:{standard_copy:N.content,forbidden_copy:N.forbidden,risk_note:N.risk_note}}),x.success(`知识卡已发布`),A.value=!1,Object.assign(N,{scenario_id:void 0,title:``,content:``,forbidden:``,risk_note:``}),await F()}catch(e){x.error(g(e))}}function L(e){S.confirm({title:`归档“${e.title}”?`,okType:`danger`,async onOk(){await v.delete(`/knowledge-cards/${e.id}`),await F()}})}function R(e,t){return e.content?.[t]||``}return u(F),(n,i)=>{let u=y(`a-button`),d=y(`a-tag`),g=y(`a-empty`),v=y(`a-spin`),b=y(`a-select`),x=y(`a-form-item`),S=y(`a-input`),F=y(`a-textarea`),z=y(`a-form`),B=y(`a-drawer`);return s(),p(`div`,E,[a(`div`,D,[i[8]||=a(`div`,null,[a(`h1`,null,`知识卡`),a(`p`,null,`沉淀经过审核的标准表达、禁用语和风险提醒。`)],-1),r(u,{type:`primary`,onClick:i[0]||=e=>A.value=!0},{default:h(()=>[r(l(w)),i[7]||=o(`新建知识卡`,-1)]),_:1})]),r(v,{spinning:_.value},{default:h(()=>[j.value.length?(s(),p(`div`,O,[(s(!0),p(c,null,f(j.value,n=>(s(),p(`article`,{key:n.id,class:`knowledge-card surface`},[a(`header`,null,[a(`span`,null,[r(l(T))]),r(d,{color:`green`},{default:h(()=>[...i[9]||=[o(`已发布`,-1)]]),_:1})]),a(`h2`,null,t(n.title),1),a(`p`,null,t(R(n,`standard_copy`)||`暂无标准话术`),1),R(n,`risk_note`)?(s(),p(`div`,k,t(R(n,`risk_note`)),1)):e(``,!0),a(`footer`,null,[a(`span`,null,t(M.value.find(e=>e.id===n.scenario_id)?.name||`未分类场景`),1),r(u,{type:`text`,danger:``,"aria-label":`归档知识卡`,onClick:e=>L(n)},{default:h(()=>[r(l(C))]),_:1},8,[`onClick`])])]))),128))])):(s(),m(g,{key:1,description:`还没有知识卡`}))]),_:1},8,[`spinning`]),r(B,{open:A.value,"onUpdate:open":i[6]||=e=>A.value=e,title:`新建知识卡`,width:`520`},{default:h(()=>[r(z,{layout:`vertical`,model:N,onFinish:I},{default:h(()=>[r(x,{label:`所属场景`,name:`scenario_id`,rules:[{required:!0,message:`请选择场景`}]},{default:h(()=>[r(b,{value:N.scenario_id,"onUpdate:value":i[1]||=e=>N.scenario_id=e,options:P.value},null,8,[`value`,`options`])]),_:1}),r(x,{label:`标题`,name:`title`,rules:[{required:!0,message:`请输入标题`}]},{default:h(()=>[r(S,{value:N.title,"onUpdate:value":i[2]||=e=>N.title=e},null,8,[`value`])]),_:1}),r(x,{label:`标准话术`,name:`content`,rules:[{required:!0,message:`请输入标准话术`}]},{default:h(()=>[r(F,{value:N.content,"onUpdate:value":i[3]||=e=>N.content=e,rows:5},null,8,[`value`])]),_:1}),r(x,{label:`禁用表达`},{default:h(()=>[r(F,{value:N.forbidden,"onUpdate:value":i[4]||=e=>N.forbidden=e,rows:3},null,8,[`value`])]),_:1}),r(x,{label:`风险提醒`},{default:h(()=>[r(F,{value:N.risk_note,"onUpdate:value":i[5]||=e=>N.risk_note=e,rows:3},null,8,[`value`])]),_:1}),r(u,{type:`primary`,"html-type":`submit`,block:``},{default:h(()=>[...i[10]||=[o(`发布知识卡`,-1)]]),_:1})]),_:1},8,[`model`])]),_:1},8,[`open`])])}}}),[[`__scopeId`,`data-v-fc5e74e6`]]);export{A as default};
|
||||||
1
codes/web/dist/assets/KnowledgeView-zQYEMPl4.css
vendored
Normal file
1
codes/web/dist/assets/KnowledgeView-zQYEMPl4.css
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
.knowledge-grid[data-v-fc5e74e6]{grid-template-columns:repeat(3,minmax(0,1fr));gap:14px;display:grid}.knowledge-card[data-v-fc5e74e6]{flex-direction:column;min-height:250px;padding:20px;display:flex}.knowledge-card header[data-v-fc5e74e6]{justify-content:space-between;display:flex}.knowledge-card header>span[data-v-fc5e74e6]{width:36px;height:36px;color:var(--green);background:#e8f3ef;border-radius:4px;place-items:center;display:grid}.knowledge-card h2[data-v-fc5e74e6]{margin:20px 0 10px;font-family:Noto Serif SC,serif;font-size:19px}.knowledge-card>p[data-v-fc5e74e6]{color:#4f5c56;margin:0;line-height:1.7}.risk-note[data-v-fc5e74e6]{color:#8b5b16;background:#fbf3e5;border-left:2px solid #c68a37;margin-top:14px;padding:9px 11px;font-size:12px}.knowledge-card footer[data-v-fc5e74e6]{color:var(--muted);justify-content:space-between;align-items:center;margin-top:auto;padding-top:18px;font-size:12px;display:flex}@media (width<=1050px){.knowledge-grid[data-v-fc5e74e6]{grid-template-columns:repeat(2,1fr)}}@media (width<=650px){.knowledge-grid[data-v-fc5e74e6]{grid-template-columns:1fr}}
|
||||||
1
codes/web/dist/assets/LoginView-BEx5f_cA.css
vendored
Normal file
1
codes/web/dist/assets/LoginView-BEx5f_cA.css
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
.login-page[data-v-265399d7]{background:#f8faf9;grid-template-columns:minmax(420px,1.05fr) minmax(420px,.95fr);min-height:100vh;display:grid}.identity-panel[data-v-265399d7]{color:#fff;background:#202825;flex-direction:column;min-height:640px;padding:42px 54px;display:flex;position:relative;overflow:hidden}.identity-grid[data-v-265399d7]{opacity:.12;background-image:linear-gradient(#b7c4be 1px,#0000 1px),linear-gradient(90deg,#b7c4be 1px,#0000 1px);background-size:44px 44px;position:absolute;inset:0}.brand-lockup[data-v-265399d7]{align-items:center;gap:12px;font-family:Noto Serif SC,serif;font-size:18px;font-weight:700;display:flex;position:relative}.brand-symbol[data-v-265399d7]{border:1px solid #ffffff59;border-radius:5px;grid-template-columns:repeat(3,1fr);align-items:end;gap:3px;width:34px;height:34px;padding:6px;display:grid}.brand-symbol i[data-v-265399d7]{background:#6cc7a8;height:8px;display:block}.brand-symbol i[data-v-265399d7]:nth-child(2){height:15px}.brand-symbol i[data-v-265399d7]:nth-child(3){background:#ed8e6a;height:22px}.identity-copy[data-v-265399d7]{max-width:600px;margin:auto 0;position:relative}.eyebrow[data-v-265399d7]{font-size:12px;font-weight:700;color:#86d3b8!important}.identity-copy h1[data-v-265399d7]{letter-spacing:0;margin:20px 0 24px;font-family:Noto Serif SC,Songti SC,serif;font-size:clamp(38px,4.2vw,64px);line-height:1.2}.identity-copy p[data-v-265399d7]{color:#bdc8c3;max-width:510px;font-size:16px;line-height:1.8}.signal-line[data-v-265399d7]{color:#91a29b;align-items:center;gap:12px;font-size:12px;display:flex;position:relative}.signal-line b[data-v-265399d7]{background:#52615b;width:70px;height:1px}.login-panel[data-v-265399d7]{place-items:center;padding:40px;display:grid}.login-form-wrap[data-v-265399d7]{width:min(100%,390px)}.login-form-wrap h2[data-v-265399d7]{color:#202825;letter-spacing:0;margin:0;font-family:Noto Serif SC,serif;font-size:28px}.login-form-wrap>p[data-v-265399d7]{color:#728079;margin:8px 0 30px}.login-form-wrap[data-v-265399d7] .ant-form-item-label label{color:#46514c;font-weight:600}.login-form-wrap[data-v-265399d7] .ant-input-affix-wrapper{padding:10px 12px}.login-form-wrap[data-v-265399d7] .ant-btn-lg{height:44px;margin-top:8px}.login-note[data-v-265399d7]{color:#8a9691;align-items:center;gap:8px;margin-top:20px;font-size:12px;display:flex}.login-note span[data-v-265399d7]{background:#d5644a;border-radius:50%;width:7px;height:7px}.mobile-brand[data-v-265399d7]{display:none}@media (width<=860px){.login-page[data-v-265399d7]{grid-template-columns:1fr}.identity-panel[data-v-265399d7]{display:none}.login-panel[data-v-265399d7]{min-height:100vh;padding:28px}.mobile-brand[data-v-265399d7]{color:#16775b;margin-bottom:48px;font-family:Noto Serif SC,serif;font-weight:700;display:block}}
|
||||||
1
codes/web/dist/assets/LoginView-CX7kNBHw.js
vendored
Normal file
1
codes/web/dist/assets/LoginView-CX7kNBHw.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
codes/web/dist/assets/PlayCircleOutlined-C2UGH7h6.js
vendored
Normal file
1
codes/web/dist/assets/PlayCircleOutlined-C2UGH7h6.js
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
import{F as e,a as t}from"./client-CO11mUW5.js";var n={icon:{tag:`svg`,attrs:{viewBox:`64 64 896 896`,focusable:`false`},children:[{tag:`path`,attrs:{d:`M512 64C264.6 64 64 264.6 64 512s200.6 448 448 448 448-200.6 448-448S759.4 64 512 64zm0 820c-205.4 0-372-166.6-372-372s166.6-372 372-372 372 166.6 372 372-166.6 372-372 372z`}},{tag:`path`,attrs:{d:`M719.4 499.1l-296.1-215A15.9 15.9 0 00398 297v430c0 13.1 14.8 20.5 25.3 12.9l296.1-215a15.9 15.9 0 000-25.8zm-257.6 134V390.9L628.5 512 461.8 633.1z`}}]},name:`play-circle`,theme:`outlined`};function r(e){for(var t=1;t<arguments.length;t++){var n=arguments[t]==null?{}:Object(arguments[t]),r=Object.keys(n);typeof Object.getOwnPropertySymbols==`function`&&(r=r.concat(Object.getOwnPropertySymbols(n).filter(function(e){return Object.getOwnPropertyDescriptor(n,e).enumerable}))),r.forEach(function(t){i(e,t,n[t])})}return e}function i(e,t,n){return t in e?Object.defineProperty(e,t,{value:n,enumerable:!0,configurable:!0,writable:!0}):e[t]=n,e}var a=function(i,a){var o=r({},i,a.attrs);return e(t,r({},o,{icon:n}),null)};a.displayName=`PlayCircleOutlined`,a.inheritAttrs=!1;export{a as t};
|
||||||
1
codes/web/dist/assets/PlusOutlined-5Urx-Evx.js
vendored
Normal file
1
codes/web/dist/assets/PlusOutlined-5Urx-Evx.js
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
import{F as e,a as t}from"./client-CO11mUW5.js";var n={icon:{tag:`svg`,attrs:{viewBox:`64 64 896 896`,focusable:`false`},children:[{tag:`path`,attrs:{d:`M482 152h60q8 0 8 8v704q0 8-8 8h-60q-8 0-8-8V160q0-8 8-8z`}},{tag:`path`,attrs:{d:`M192 474h672q8 0 8 8v60q0 8-8 8H160q-8 0-8-8v-60q0-8 8-8z`}}]},name:`plus`,theme:`outlined`};function r(e){for(var t=1;t<arguments.length;t++){var n=arguments[t]==null?{}:Object(arguments[t]),r=Object.keys(n);typeof Object.getOwnPropertySymbols==`function`&&(r=r.concat(Object.getOwnPropertySymbols(n).filter(function(e){return Object.getOwnPropertyDescriptor(n,e).enumerable}))),r.forEach(function(t){i(e,t,n[t])})}return e}function i(e,t,n){return t in e?Object.defineProperty(e,t,{value:n,enumerable:!0,configurable:!0,writable:!0}):e[t]=n,e}var a=function(i,a){var o=r({},i,a.attrs);return e(t,r({},o,{icon:n}),null)};a.displayName=`PlusOutlined`,a.inheritAttrs=!1;export{a as t};
|
||||||
1
codes/web/dist/assets/RunHistoryView-B07xvIJS.css
vendored
Normal file
1
codes/web/dist/assets/RunHistoryView-B07xvIJS.css
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
code[data-v-4d911d56]{color:#17654f;background:#eef5f2;border-radius:3px;padding:2px 5px}
|
||||||
1
codes/web/dist/assets/RunHistoryView-BpCB2LRG.js
vendored
Normal file
1
codes/web/dist/assets/RunHistoryView-BpCB2LRG.js
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
import{A as e,Bt as t,F as n,I as r,O as i,P as a,Q as o,S as s,Y as c,j as l,k as u,lt as d,n as f,r as p,t as m,tt as h,vt as g}from"./client-CO11mUW5.js";import{a as _}from"./config-provider-kwhtQ-D4.js";var v={class:`page-shell`},y={class:`surface`},b={key:0},x=p(r({__name:`RunHistoryView`,setup(r){let p=g(!1),x=g([]);async function S(){p.value=!0;try{x.value=(await m.get(`/runs`)).items}catch(e){_.error(f(e))}finally{p.value=!1}}function C(e){return e===`completed`?`已完成`:`进行中`}return c(S),(r,c)=>{let f=h(`a-tag`),m=h(`a-table`);return o(),l(`div`,v,[c[1]||=i(`div`,{class:`page-heading`},[i(`div`,null,[i(`h1`,null,`执行记录`),i(`p`,null,`查看 SOP 的实际使用情况和执行结果。`)])],-1),i(`section`,y,[n(m,{"row-key":`id`,loading:p.value,"data-source":x.value,pagination:{pageSize:20},columns:[{title:`执行编号`,dataIndex:`id`,width:130},{title:`SOP`,dataIndex:`sop_name`},{title:`状态`,dataIndex:`status`,width:110},{title:`结果`,dataIndex:`result`,width:120},{title:`开始时间`,dataIndex:`started_at`,width:190},{title:`完成时间`,dataIndex:`completed_at`,width:190}],scroll:{x:900}},{bodyCell:d(({column:n,record:r})=>[n.dataIndex===`id`?(o(),l(`code`,b,`RUN-`+t(String(r.id).padStart(5,`0`)),1)):n.dataIndex===`status`?(o(),u(f,{key:1,color:r.status===`completed`?`green`:`orange`},{default:d(()=>[a(t(C(r.status)),1)]),_:2},1032,[`color`])):n.dataIndex===`result`?(o(),l(s,{key:2},[a(t(r.result||`-`),1)],64)):n.dataIndex===`started_at`||n.dataIndex===`completed_at`?(o(),l(s,{key:3},[a(t(r[n.dataIndex]?new Date(r[n.dataIndex]).toLocaleString(`zh-CN`):`-`),1)],64)):e(``,!0)]),emptyText:d(()=>[...c[0]||=[i(`div`,{class:`empty-copy`},`还没有执行记录。发布 SOP 后即可开始执行。`,-1)]]),_:1},8,[`loading`,`data-source`])])])}}}),[[`__scopeId`,`data-v-4d911d56`]]);export{x as default};
|
||||||
3
codes/web/dist/assets/SOPEditorView-CmiIrIPT.js
vendored
Normal file
3
codes/web/dist/assets/SOPEditorView-CmiIrIPT.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
codes/web/dist/assets/SOPEditorView-DdGSx6p0.css
vendored
Normal file
1
codes/web/dist/assets/SOPEditorView-DdGSx6p0.css
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
.editor-page[data-v-06435891]{background:#eef1ef;min-height:calc(100vh - 62px)}.editor-header[data-v-06435891]{border-bottom:1px solid var(--line);z-index:10;background:#fff;justify-content:space-between;align-items:center;gap:16px;min-height:66px;padding:10px 24px;display:flex;position:sticky;top:62px}.editor-title[data-v-06435891]{align-items:center;gap:8px;display:flex}.editor-title>div[data-v-06435891]{flex-direction:column;display:flex}.editor-title b[data-v-06435891]{font-size:15px}.editor-title span[data-v-06435891]{color:var(--muted);margin-top:3px;font-size:11px}.editor-workbench[data-v-06435891]{grid-template-columns:280px minmax(0,1fr);gap:16px;max-width:1500px;margin:0 auto;padding:18px;display:grid}.node-rail[data-v-06435891]{align-self:start;position:sticky;top:146px;overflow:hidden}.rail-title[data-v-06435891]{border-bottom:1px solid var(--line);justify-content:space-between;align-items:center;height:50px;padding:0 14px;font-weight:650;display:flex}.node-list[data-v-06435891]{padding:8px}.node-list button[data-v-06435891]{text-align:left;cursor:pointer;background:0 0;border:1px solid #0000;border-radius:4px;grid-template-columns:34px 1fr auto;align-items:center;gap:9px;width:100%;min-height:58px;padding:7px 8px;display:grid}.node-list button[data-v-06435891]:hover{background:#f5f8f6}.node-list button.active[data-v-06435891]{background:#edf6f2;border-color:#b9d8cc}.node-index[data-v-06435891]{color:#88958f;font-family:monospace;font-size:11px}.node-meta[data-v-06435891]{flex-direction:column;min-width:0;display:flex}.node-meta b[data-v-06435891]{text-overflow:ellipsis;white-space:nowrap;font-size:13px;overflow:hidden}.node-meta small[data-v-06435891]{color:var(--muted);margin-top:3px;font-size:11px}.node-move[data-v-06435891]{color:#87938e;gap:4px;display:flex}.node-move[data-v-06435891]>:hover{color:var(--green)}.node-editor[data-v-06435891]{align-self:start;min-height:600px;padding:24px}.node-editor-head[data-v-06435891]{border-bottom:1px solid var(--line);justify-content:space-between;align-items:flex-start;padding-bottom:18px;display:flex}.node-editor-head h2[data-v-06435891]{margin:6px 0 0;font-family:Noto Serif SC,serif;font-size:22px}.node-type-mark[data-v-06435891]{color:var(--green);font-size:10px;font-weight:750}.property-form[data-v-06435891]{padding-top:22px}.property-grid[data-v-06435891]{grid-template-columns:1fr 1fr;gap:16px;display:grid}.transition-head[data-v-06435891]{border-top:1px solid var(--line);justify-content:space-between;margin:8px -24px 0;padding:18px 24px 10px;display:flex}.transition-head>div[data-v-06435891]{flex-direction:column;display:flex}.transition-head span[data-v-06435891]{color:var(--muted);margin-top:4px;font-size:11px}.transition-row[data-v-06435891]{border-bottom:1px solid #edf0ee;grid-template-columns:20px minmax(120px,1fr) auto 36px;align-items:center;gap:10px;min-height:48px;display:grid}.route-line[data-v-06435891]{background:var(--green);width:16px;height:1px}.route-target[data-v-06435891]{font-weight:600}.route-empty[data-v-06435891]{color:var(--muted);padding:20px 0;font-size:12px}.edge-builder[data-v-06435891]{grid-template-columns:1.1fr 1fr 120px 1fr auto;gap:8px;padding-top:16px;display:grid}.editor-empty[data-v-06435891]{place-items:center;min-height:400px;display:grid}@media (width<=1050px){.edge-builder[data-v-06435891]{grid-template-columns:1fr 1fr}.editor-workbench[data-v-06435891]{grid-template-columns:230px minmax(0,1fr)}}@media (width<=760px){.editor-header[data-v-06435891]{flex-direction:column;align-items:flex-start;padding:12px;top:62px}.editor-workbench[data-v-06435891]{grid-template-columns:1fr;padding:10px}.node-rail[data-v-06435891]{position:static}.property-grid[data-v-06435891],.edge-builder[data-v-06435891]{grid-template-columns:1fr}.node-editor[data-v-06435891]{padding:16px}.transition-head[data-v-06435891]{margin:8px -16px 0;padding:16px}}
|
||||||
2
codes/web/dist/assets/ScenarioDetailView-B-4AAfC9.js
vendored
Normal file
2
codes/web/dist/assets/ScenarioDetailView-B-4AAfC9.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
codes/web/dist/assets/ScenarioDetailView-DWNPSm13.css
vendored
Normal file
1
codes/web/dist/assets/ScenarioDetailView-DWNPSm13.css
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
.back-link[data-v-a1877f4c]{color:var(--muted);margin:-4px 0 12px -14px}.detail-heading[data-v-a1877f4c]{color:#fff;background:#202825;border-radius:6px;justify-content:space-between;align-items:flex-end;gap:24px;min-height:150px;padding:28px;display:flex}.detail-heading h1[data-v-a1877f4c]{letter-spacing:0;margin:10px 0 8px;font-family:Noto Serif SC,serif;font-size:28px}.detail-heading p[data-v-a1877f4c]{color:#b8c4bf;max-width:720px;margin:0}.heading-tags[data-v-a1877f4c]{color:#a8b6b0;align-items:center;gap:10px;font-size:12px;display:flex}.scenario-code[data-v-a1877f4c]{color:#70817a;align-self:flex-start;font-size:12px}.detail-tabs[data-v-a1877f4c]{margin-top:18px}.toolbar>div[data-v-a1877f4c]{align-items:baseline;gap:12px;display:flex}.toolbar-note[data-v-a1877f4c]{color:var(--muted);font-size:12px}.field-grid[data-v-a1877f4c]{grid-template-columns:1fr 1fr;gap:14px;display:grid}code[data-v-a1877f4c]{color:#17654f;background:#eef5f2;border-radius:3px;padding:2px 5px}.sop-list button[data-v-a1877f4c]{text-align:left;border:0;border-bottom:1px solid var(--line);cursor:pointer;background:#fff;grid-template-columns:42px 1fr auto 100px;align-items:center;gap:14px;width:100%;min-height:74px;padding:12px 18px;display:grid}.sop-list button[data-v-a1877f4c]:hover{background:#f8faf9}.sop-icon[data-v-a1877f4c]{width:38px;height:38px;color:var(--green);background:#e8f3ef;border-radius:4px;place-items:center;display:grid}.sop-copy[data-v-a1877f4c]{flex-direction:column;min-width:0;display:flex}.sop-copy small[data-v-a1877f4c]{color:var(--muted);text-overflow:ellipsis;white-space:nowrap;margin-top:4px;overflow:hidden}.sop-date[data-v-a1877f4c]{color:var(--muted);text-align:right;font-size:12px}.empty-state[data-v-a1877f4c]{color:#82908a;text-align:center;padding:64px 20px}.empty-state>span[data-v-a1877f4c]{font-size:32px}.empty-state p[data-v-a1877f4c]{margin:12px 0 0}@media (width<=650px){.detail-heading[data-v-a1877f4c]{flex-direction:column;align-items:flex-start}.scenario-code[data-v-a1877f4c]{display:none}.field-grid[data-v-a1877f4c]{grid-template-columns:1fr;gap:0}.sop-list button[data-v-a1877f4c]{grid-template-columns:36px 1fr auto}.sop-date[data-v-a1877f4c]{display:none}}
|
||||||
1
codes/web/dist/assets/ScenariosView-CwB6WxAz.css
vendored
Normal file
1
codes/web/dist/assets/ScenariosView-CwB6WxAz.css
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
.scenario-name[data-v-3965a2e5]{cursor:pointer;flex-direction:column;max-width:460px;display:flex}.scenario-name b[data-v-3965a2e5]{font-size:14px}.scenario-name small[data-v-3965a2e5]{color:var(--muted);text-overflow:ellipsis;white-space:nowrap;margin-top:4px;overflow:hidden}.form-grid[data-v-3965a2e5]{grid-template-columns:1fr 1fr;gap:14px;display:grid}[data-v-3965a2e5] .ant-table-row{cursor:pointer}@media (width<=600px){.form-grid[data-v-3965a2e5]{grid-template-columns:1fr;gap:0}}
|
||||||
1
codes/web/dist/assets/ScenariosView-DZfzIhhk.js
vendored
Normal file
1
codes/web/dist/assets/ScenariosView-DZfzIhhk.js
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
import{A as e,Bt as t,F as n,I as r,O as i,P as a,Q as o,S as s,Tt as c,Y as l,_t as u,j as d,k as f,lt as p,n as m,r as h,t as g,tt as _,vt as v}from"./client-CO11mUW5.js";import{a as y}from"./config-provider-kwhtQ-D4.js";import{t as b}from"./SearchOutlined-DRcWUFRC.js";import{t as x}from"./PlusOutlined-5Urx-Evx.js";import{h as S}from"./useApi-CROJJdhE-BlzMTLF9.js";var C={class:`page-shell`},w={class:`page-heading`},T={class:`surface`},E={class:`toolbar`},D={class:`muted`},O={key:0,class:`scenario-name`},k={class:`form-grid`},A=h(r({__name:`ScenariosView`,setup(r){let h=S(),A=v(!1),j=v(!1),M=v(``),N=v([]),P=u({name:``,industry:``,role_name:``,goal:``,trigger_text:``,visibility:`tenant`}),F=[{title:`场景名称`,dataIndex:`name`,key:`name`},{title:`行业`,dataIndex:`industry`,key:`industry`,width:140},{title:`适用角色`,dataIndex:`role_name`,key:`role_name`,width:160},{title:`状态`,dataIndex:`status`,key:`status`,width:110},{title:`更新时间`,dataIndex:`updated_at`,key:`updated_at`,width:180},{title:``,key:`action`,width:80}];async function I(){A.value=!0;try{N.value=(await g.get(`/scenarios`,{params:{keyword:M.value}})).items}finally{A.value=!1}}async function L(){try{let e=await g.post(`/scenarios`,P);y.success(`场景已创建`),j.value=!1,h.push(`/scenarios/${e.id}`)}catch(e){y.error(m(e))}}function R(e){return{draft:`草稿`,active:`使用中`,archived:`已归档`}[e]||e}return l(I),(r,l)=>{let u=_(`a-button`),m=_(`a-input`),g=_(`a-tag`),v=_(`a-table`),y=_(`a-form-item`),S=_(`a-textarea`),z=_(`a-radio`),B=_(`a-radio-group`),V=_(`a-form`),H=_(`a-drawer`);return o(),d(`div`,C,[i(`div`,w,[l[10]||=i(`div`,null,[i(`h1`,null,`场景与 SOP`),i(`p`,null,`从业务触发点开始,定义信息字段与可执行话术流程。`)],-1),n(u,{type:`primary`,onClick:l[0]||=e=>j.value=!0},{default:p(()=>[n(c(x)),l[9]||=a(`新建场景`,-1)]),_:1})]),i(`section`,T,[i(`div`,E,[n(m,{value:M.value,"onUpdate:value":l[1]||=e=>M.value=e,"allow-clear":``,placeholder:`搜索名称或行业`,style:{width:`280px`},onPressEnter:I},{prefix:p(()=>[n(c(b))]),_:1},8,[`value`]),i(`span`,D,`共 `+t(N.value.length)+` 个场景`,1)]),n(v,{columns:F,"data-source":N.value,loading:A.value,"row-key":`id`,pagination:!1,scroll:{x:800},"custom-row":e=>({onClick:()=>c(h).push(`/scenarios/${e.id}`)})},{bodyCell:p(({column:n,record:r})=>[n.key===`name`?(o(),d(`div`,O,[i(`b`,null,t(r.name),1),i(`small`,null,t(r.goal),1)])):n.key===`status`?(o(),f(g,{key:1,color:r.status===`active`?`green`:`default`},{default:p(()=>[a(t(R(r.status)),1)]),_:2},1032,[`color`])):n.key===`updated_at`?(o(),d(s,{key:2},[a(t(new Date(r.updated_at).toLocaleString(`zh-CN`)),1)],64)):n.key===`action`?(o(),f(u,{key:3,type:`link`,size:`small`},{default:p(()=>[...l[11]||=[a(`配置`,-1)]]),_:1})):e(``,!0)]),emptyText:p(()=>[...l[12]||=[i(`div`,{class:`empty-copy`},`还没有业务场景。创建第一个场景后,就可以配置字段和 SOP。`,-1)]]),_:1},8,[`data-source`,`loading`,`custom-row`])]),n(H,{open:j.value,"onUpdate:open":l[8]||=e=>j.value=e,title:`创建业务场景`,width:`520`,"destroy-on-close":!0},{default:p(()=>[n(V,{layout:`vertical`,model:P},{default:p(()=>[n(y,{label:`场景名称`,name:`name`,rules:[{required:!0,message:`请输入场景名称`}]},{default:p(()=>[n(m,{value:P.name,"onUpdate:value":l[2]||=e=>P.name=e,placeholder:`例如:宠物医生问诊问药`},null,8,[`value`])]),_:1}),i(`div`,k,[n(y,{label:`所属行业`,name:`industry`,rules:[{required:!0,message:`请输入行业`}]},{default:p(()=>[n(m,{value:P.industry,"onUpdate:value":l[3]||=e=>P.industry=e,placeholder:`宠物医疗`},null,8,[`value`])]),_:1}),n(y,{label:`适用角色`,name:`role_name`,rules:[{required:!0,message:`请输入角色`}]},{default:p(()=>[n(m,{value:P.role_name,"onUpdate:value":l[4]||=e=>P.role_name=e,placeholder:`医生、客服`},null,8,[`value`])]),_:1})]),n(y,{label:`执行目标`,name:`goal`,rules:[{required:!0,message:`请输入执行目标`}]},{default:p(()=>[n(S,{value:P.goal,"onUpdate:value":l[5]||=e=>P.goal=e,rows:3,placeholder:`这个场景最终要推动什么结果`},null,8,[`value`])]),_:1}),n(y,{label:`触发条件`,name:`trigger_text`,rules:[{required:!0,message:`请输入触发条件`}]},{default:p(()=>[n(S,{value:P.trigger_text,"onUpdate:value":l[6]||=e=>P.trigger_text=e,rows:3,placeholder:`什么时候进入这个场景`},null,8,[`value`])]),_:1}),n(y,{label:`可见范围`},{default:p(()=>[n(B,{value:P.visibility,"onUpdate:value":l[7]||=e=>P.visibility=e},{default:p(()=>[n(z,{value:`tenant`},{default:p(()=>[...l[13]||=[a(`全企业`,-1)]]),_:1}),n(z,{value:`team`},{default:p(()=>[...l[14]||=[a(`团队`,-1)]]),_:1}),n(z,{value:`private`},{default:p(()=>[...l[15]||=[a(`仅自己`,-1)]]),_:1})]),_:1},8,[`value`])]),_:1}),n(u,{type:`primary`,block:``,onClick:L},{default:p(()=>[...l[16]||=[a(`创建并继续配置`,-1)]]),_:1})]),_:1},8,[`model`])]),_:1},8,[`open`])])}}}),[[`__scopeId`,`data-v-3965a2e5`]]);export{A as default};
|
||||||
1
codes/web/dist/assets/SearchOutlined-DRcWUFRC.js
vendored
Normal file
1
codes/web/dist/assets/SearchOutlined-DRcWUFRC.js
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
import{F as e,a as t}from"./client-CO11mUW5.js";var n={icon:{tag:`svg`,attrs:{viewBox:`64 64 896 896`,focusable:`false`},children:[{tag:`path`,attrs:{d:`M909.6 854.5L649.9 594.8C690.2 542.7 712 479 712 412c0-80.2-31.3-155.4-87.9-212.1-56.6-56.7-132-87.9-212.1-87.9s-155.5 31.3-212.1 87.9C143.2 256.5 112 331.8 112 412c0 80.1 31.3 155.5 87.9 212.1C256.5 680.8 331.8 712 412 712c67 0 130.6-21.8 182.7-62l259.7 259.6a8.2 8.2 0 0011.6 0l43.6-43.5a8.2 8.2 0 000-11.6zM570.4 570.4C528 612.7 471.8 636 412 636s-116-23.3-158.4-65.6C211.3 528 188 471.8 188 412s23.3-116.1 65.6-158.4C296 211.3 352.2 188 412 188s116.1 23.2 158.4 65.6S636 352.2 636 412s-23.3 116.1-65.6 158.4z`}}]},name:`search`,theme:`outlined`};function r(e){for(var t=1;t<arguments.length;t++){var n=arguments[t]==null?{}:Object(arguments[t]),r=Object.keys(n);typeof Object.getOwnPropertySymbols==`function`&&(r=r.concat(Object.getOwnPropertySymbols(n).filter(function(e){return Object.getOwnPropertyDescriptor(n,e).enumerable}))),r.forEach(function(t){i(e,t,n[t])})}return e}function i(e,t,n){return t in e?Object.defineProperty(e,t,{value:n,enumerable:!0,configurable:!0,writable:!0}):e[t]=n,e}var a=function(i,a){var o=r({},i,a.attrs);return e(t,r({},o,{icon:n}),null)};a.displayName=`SearchOutlined`,a.inheritAttrs=!1;export{a as t};
|
||||||
1
codes/web/dist/assets/auth-D7KJ41TQ.js
vendored
Normal file
1
codes/web/dist/assets/auth-D7KJ41TQ.js
vendored
Normal file
File diff suppressed because one or more lines are too long
65
codes/web/dist/assets/client-CO11mUW5.js
vendored
Normal file
65
codes/web/dist/assets/client-CO11mUW5.js
vendored
Normal file
File diff suppressed because one or more lines are too long
55
codes/web/dist/assets/config-provider-kwhtQ-D4.js
vendored
Normal file
55
codes/web/dist/assets/config-provider-kwhtQ-D4.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
codes/web/dist/assets/index-BM4AsfIr.css
vendored
Normal file
1
codes/web/dist/assets/index-BM4AsfIr.css
vendored
Normal file
File diff suppressed because one or more lines are too long
327
codes/web/dist/assets/index-DL8F6dio.js
vendored
Normal file
327
codes/web/dist/assets/index-DL8F6dio.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
codes/web/dist/assets/useApi-CROJJdhE-BlzMTLF9.js
vendored
Normal file
1
codes/web/dist/assets/useApi-CROJJdhE-BlzMTLF9.js
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
import{V as e}from"./client-CO11mUW5.js";function t(e){return typeof e==`object`||`displayName`in e||`props`in e||`__vccOpts`in e}function n(e){return e.__esModule||e[Symbol.toStringTag]===`Module`||e.default&&t(e.default)}var r=Object.assign;function i(e,t){let n={};for(let r in t){let i=t[r];n[r]=o(i)?i.map(e):e(i)}return n}var a=()=>{},o=Array.isArray;function s(e,t){let n={};for(let r in e)n[r]=r in t?t[r]:e[r];return n}var c=Symbol(``);function l(e,t){return r(Error(),{type:e,[c]:!0},t)}function u(e,t){return e instanceof Error&&c in e&&(t==null||!!(e.type&t))}var d=Symbol(``),f=Symbol(``),p=Symbol(``),m=Symbol(``),h=Symbol(``);function g(){return e(p)}function _(t){return e(m)}export{n as a,d as c,m as d,p as f,f as g,g as h,o as i,s as l,_ as m,r as n,u as o,h as p,l as r,t as s,i as t,a as u};
|
||||||
25
codes/web/dist/index.html
vendored
Normal file
25
codes/web/dist/index.html
vendored
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<meta name="theme-color" content="#202825" />
|
||||||
|
<title>销冠 SOP 平台</title>
|
||||||
|
<script type="module" crossorigin src="/assets/index-DL8F6dio.js"></script>
|
||||||
|
<link rel="modulepreload" crossorigin href="/assets/client-CO11mUW5.js">
|
||||||
|
<link rel="modulepreload" crossorigin href="/assets/config-provider-kwhtQ-D4.js">
|
||||||
|
<link rel="modulepreload" crossorigin href="/assets/auth-D7KJ41TQ.js">
|
||||||
|
<link rel="modulepreload" crossorigin href="/assets/DeleteOutlined-Dsl9pMnk.js">
|
||||||
|
<link rel="modulepreload" crossorigin href="/assets/SearchOutlined-DRcWUFRC.js">
|
||||||
|
<link rel="modulepreload" crossorigin href="/assets/PlusOutlined-5Urx-Evx.js">
|
||||||
|
<link rel="modulepreload" crossorigin href="/assets/ArrowLeftOutlined-m6YdiMlD.js">
|
||||||
|
<link rel="modulepreload" crossorigin href="/assets/AppstoreOutlined-BOx6VBGQ.js">
|
||||||
|
<link rel="modulepreload" crossorigin href="/assets/BookOutlined-CNUY9qCc.js">
|
||||||
|
<link rel="modulepreload" crossorigin href="/assets/PlayCircleOutlined-C2UGH7h6.js">
|
||||||
|
<link rel="modulepreload" crossorigin href="/assets/useApi-CROJJdhE-BlzMTLF9.js">
|
||||||
|
<link rel="stylesheet" crossorigin href="/assets/index-BM4AsfIr.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
13
codes/web/index.html
Normal file
13
codes/web/index.html
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<meta name="theme-color" content="#202825" />
|
||||||
|
<title>销冠 SOP 平台</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/main.ts"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
2291
codes/web/package-lock.json
generated
Normal file
2291
codes/web/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
27
codes/web/package.json
Normal file
27
codes/web/package.json
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"name": "iqudo-top1-web",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.1.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vue-tsc --noEmit && vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@ant-design/icons-vue": "7.0.1",
|
||||||
|
"ant-design-vue": "4.2.6",
|
||||||
|
"axios": "1.19.0",
|
||||||
|
"dayjs": "1.11.21",
|
||||||
|
"pinia": "4.0.2",
|
||||||
|
"vue": "3.5.41",
|
||||||
|
"vue-router": "5.2.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "26.1.2",
|
||||||
|
"@vitejs/plugin-vue": "6.0.8",
|
||||||
|
"typescript": "5.9.3",
|
||||||
|
"vite": "8.2.0",
|
||||||
|
"vue-tsc": "3.3.9"
|
||||||
|
}
|
||||||
|
}
|
||||||
3
codes/web/src/App.vue
Normal file
3
codes/web/src/App.vue
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
<template>
|
||||||
|
<router-view />
|
||||||
|
</template>
|
||||||
46
codes/web/src/api/client.ts
Normal file
46
codes/web/src/api/client.ts
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import axios, { type AxiosRequestConfig } from 'axios'
|
||||||
|
|
||||||
|
interface Envelope<T> { code: string; message: string; data: T }
|
||||||
|
|
||||||
|
const client = axios.create({ baseURL: '/api/v1', timeout: 15000 })
|
||||||
|
|
||||||
|
client.interceptors.request.use((config) => {
|
||||||
|
const token = localStorage.getItem('access_token')
|
||||||
|
if (token) config.headers.Authorization = `Bearer ${token}`
|
||||||
|
return config
|
||||||
|
})
|
||||||
|
|
||||||
|
client.interceptors.response.use(
|
||||||
|
(response) => response,
|
||||||
|
(error) => {
|
||||||
|
if (error.response?.status === 401 && !error.config?.url?.includes('/auth/login')) {
|
||||||
|
localStorage.removeItem('access_token')
|
||||||
|
localStorage.removeItem('refresh_token')
|
||||||
|
if (window.location.pathname !== '/login') window.location.href = '/login'
|
||||||
|
}
|
||||||
|
return Promise.reject(error)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
export const api = {
|
||||||
|
async get<T>(url: string, config?: AxiosRequestConfig): Promise<T> {
|
||||||
|
const response = await client.get<Envelope<T>>(url, config)
|
||||||
|
return response.data.data
|
||||||
|
},
|
||||||
|
async post<T>(url: string, data?: unknown): Promise<T> {
|
||||||
|
const response = await client.post<Envelope<T>>(url, data)
|
||||||
|
return response.data.data
|
||||||
|
},
|
||||||
|
async put<T>(url: string, data?: unknown): Promise<T> {
|
||||||
|
const response = await client.put<Envelope<T>>(url, data)
|
||||||
|
return response.data.data
|
||||||
|
},
|
||||||
|
async delete<T>(url: string): Promise<T> {
|
||||||
|
const response = await client.delete<Envelope<T>>(url)
|
||||||
|
return response.data.data
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export function apiMessage(error: any): string {
|
||||||
|
return error?.response?.data?.message || error?.message || '操作失败,请稍后重试'
|
||||||
|
}
|
||||||
1
codes/web/src/env.d.ts
vendored
Normal file
1
codes/web/src/env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
121
codes/web/src/layouts/AppLayout.vue
Normal file
121
codes/web/src/layouts/AppLayout.vue
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, ref } from 'vue'
|
||||||
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
import {
|
||||||
|
AppstoreOutlined,
|
||||||
|
BookOutlined,
|
||||||
|
DashboardOutlined,
|
||||||
|
HistoryOutlined,
|
||||||
|
LogoutOutlined,
|
||||||
|
MenuFoldOutlined,
|
||||||
|
MenuUnfoldOutlined,
|
||||||
|
PlayCircleOutlined,
|
||||||
|
} from '@ant-design/icons-vue'
|
||||||
|
|
||||||
|
const collapsed = ref(false)
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
const auth = useAuthStore()
|
||||||
|
|
||||||
|
const selectedKeys = computed(() => {
|
||||||
|
if (route.path.startsWith('/scenarios') || route.path.startsWith('/sops')) return ['scenarios']
|
||||||
|
if (route.path.startsWith('/execute')) return ['execute']
|
||||||
|
if (route.path.startsWith('/runs')) return ['runs']
|
||||||
|
if (route.path.startsWith('/knowledge')) return ['knowledge']
|
||||||
|
return ['dashboard']
|
||||||
|
})
|
||||||
|
|
||||||
|
const titles: Record<string, string> = {
|
||||||
|
dashboard: '工作台', scenarios: '场景与 SOP', execute: '执行话术', runs: '执行记录', knowledge: '知识卡',
|
||||||
|
}
|
||||||
|
const pageTitle = computed(() => titles[selectedKeys.value[0]] || '销冠 SOP')
|
||||||
|
|
||||||
|
onMounted(() => auth.loadUser().catch(() => auth.logout()))
|
||||||
|
|
||||||
|
function navigate({ key }: { key: string }) {
|
||||||
|
const target: Record<string, string> = { dashboard: '/', scenarios: '/scenarios', execute: '/execute', runs: '/runs', knowledge: '/knowledge' }
|
||||||
|
router.push(target[key])
|
||||||
|
}
|
||||||
|
|
||||||
|
function logout() {
|
||||||
|
auth.logout()
|
||||||
|
router.push('/login')
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<a-layout class="app-frame">
|
||||||
|
<a-layout-sider v-model:collapsed="collapsed" :trigger="null" collapsible :width="224" class="side-panel">
|
||||||
|
<div class="brand" :class="{ compact: collapsed }">
|
||||||
|
<div class="brand-mark"><span></span><span></span><span></span></div>
|
||||||
|
<div v-if="!collapsed" class="brand-copy">
|
||||||
|
<strong>销冠 SOP</strong>
|
||||||
|
<small>经验执行系统</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<a-menu mode="inline" theme="dark" :selected-keys="selectedKeys" @click="navigate">
|
||||||
|
<a-menu-item key="dashboard"><DashboardOutlined /><span>工作台</span></a-menu-item>
|
||||||
|
<a-menu-item key="scenarios"><AppstoreOutlined /><span>场景与 SOP</span></a-menu-item>
|
||||||
|
<a-menu-item key="execute"><PlayCircleOutlined /><span>执行话术</span></a-menu-item>
|
||||||
|
<a-menu-item key="runs"><HistoryOutlined /><span>执行记录</span></a-menu-item>
|
||||||
|
<a-menu-item key="knowledge"><BookOutlined /><span>知识卡</span></a-menu-item>
|
||||||
|
</a-menu>
|
||||||
|
<div class="sider-foot" :class="{ compact: collapsed }">
|
||||||
|
<span class="online-dot"></span>
|
||||||
|
<span v-if="!collapsed">服务运行正常</span>
|
||||||
|
</div>
|
||||||
|
</a-layout-sider>
|
||||||
|
|
||||||
|
<a-layout>
|
||||||
|
<a-layout-header class="topbar">
|
||||||
|
<a-button type="text" class="collapse-button" :aria-label="collapsed ? '展开导航' : '收起导航'" @click="collapsed = !collapsed">
|
||||||
|
<MenuUnfoldOutlined v-if="collapsed" /><MenuFoldOutlined v-else />
|
||||||
|
</a-button>
|
||||||
|
<div class="topbar-title">{{ pageTitle }}</div>
|
||||||
|
<a-dropdown placement="bottomRight">
|
||||||
|
<button class="user-button">
|
||||||
|
<span class="avatar">{{ (auth.user?.display_name || '管').slice(0, 1) }}</span>
|
||||||
|
<span class="user-copy">
|
||||||
|
<strong>{{ auth.user?.display_name || '平台管理员' }}</strong>
|
||||||
|
<small>{{ auth.user?.role_code === 'admin' ? '企业管理员' : auth.user?.role_code }}</small>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<template #overlay>
|
||||||
|
<a-menu><a-menu-item key="logout" @click="logout"><LogoutOutlined /> 退出登录</a-menu-item></a-menu>
|
||||||
|
</template>
|
||||||
|
</a-dropdown>
|
||||||
|
</a-layout-header>
|
||||||
|
<a-layout-content class="content-area"><router-view /></a-layout-content>
|
||||||
|
</a-layout>
|
||||||
|
</a-layout>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.app-frame { min-height: 100vh; }
|
||||||
|
.side-panel { position: sticky; top: 0; height: 100vh; background: #202825 !important; overflow: hidden; }
|
||||||
|
.brand { height: 78px; display: flex; align-items: center; gap: 12px; padding: 0 20px; border-bottom: 1px solid rgba(255,255,255,.09); }
|
||||||
|
.brand.compact { padding: 0; justify-content: center; }
|
||||||
|
.brand-mark { width: 32px; height: 32px; display: grid; grid-template-columns: repeat(3, 1fr); align-items: end; gap: 3px; padding: 5px; border: 1px solid rgba(255,255,255,.22); border-radius: 5px; }
|
||||||
|
.brand-mark span { background: #66c8a6; border-radius: 1px 1px 0 0; }
|
||||||
|
.brand-mark span:nth-child(1) { height: 8px; }.brand-mark span:nth-child(2) { height: 14px; }.brand-mark span:nth-child(3) { height: 20px; background: #f0a07d; }
|
||||||
|
.brand-copy { min-width: 0; color: white; display: flex; flex-direction: column; }
|
||||||
|
.brand-copy strong { font-family: "Noto Serif SC", serif; font-size: 16px; letter-spacing: 0; }
|
||||||
|
.brand-copy small { color: #a7b7b0; font-size: 11px; margin-top: 2px; }
|
||||||
|
.side-panel :deep(.ant-menu-dark) { background: transparent; padding: 12px 9px; }
|
||||||
|
.side-panel :deep(.ant-menu-item) { height: 42px; margin: 5px 0; border-radius: 4px; color: #b9c4c0; }
|
||||||
|
.side-panel :deep(.ant-menu-item-selected) { background: #166b53 !important; color: white; }
|
||||||
|
.sider-foot { position: absolute; bottom: 0; left: 0; right: 0; height: 52px; display: flex; align-items: center; gap: 8px; padding: 0 20px; color: #91a29b; font-size: 12px; border-top: 1px solid rgba(255,255,255,.08); }
|
||||||
|
.sider-foot.compact { justify-content: center; padding: 0; }
|
||||||
|
.online-dot { width: 7px; height: 7px; border-radius: 50%; background: #67caa8; box-shadow: 0 0 0 3px rgba(103,202,168,.12); }
|
||||||
|
.topbar { height: 62px; padding: 0 22px; display: flex; align-items: center; gap: 12px; line-height: normal; background: rgba(255,255,255,.94); border-bottom: 1px solid #dce2df; position: sticky; top: 0; z-index: 20; }
|
||||||
|
.collapse-button { width: 36px; height: 36px; }
|
||||||
|
.topbar-title { flex: 1; color: #52605a; font-size: 14px; }
|
||||||
|
.user-button { display: flex; align-items: center; gap: 9px; border: 0; padding: 6px 8px; background: transparent; cursor: pointer; border-radius: 5px; }
|
||||||
|
.user-button:hover { background: #f1f4f2; }
|
||||||
|
.avatar { width: 32px; height: 32px; display: grid; place-items: center; border-radius: 4px; background: #e5f2ed; color: #126248; font-weight: 700; }
|
||||||
|
.user-copy { display: flex; flex-direction: column; text-align: left; }
|
||||||
|
.user-copy strong { font-size: 13px; }.user-copy small { color: #7c8883; font-size: 11px; margin-top: 2px; }
|
||||||
|
.content-area { min-width: 0; background: #f4f6f5; }
|
||||||
|
@media (max-width: 760px) { .side-panel { display: none; }.user-copy { display: none; }.topbar { padding: 0 12px; } }
|
||||||
|
</style>
|
||||||
9
codes/web/src/main.ts
Normal file
9
codes/web/src/main.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { createApp } from 'vue'
|
||||||
|
import { createPinia } from 'pinia'
|
||||||
|
import Antd from 'ant-design-vue'
|
||||||
|
import 'ant-design-vue/dist/reset.css'
|
||||||
|
import './styles/main.css'
|
||||||
|
import App from './App.vue'
|
||||||
|
import router from './router'
|
||||||
|
|
||||||
|
createApp(App).use(createPinia()).use(router).use(Antd).mount('#app')
|
||||||
29
codes/web/src/router/index.ts
Normal file
29
codes/web/src/router/index.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import { createRouter, createWebHistory } from 'vue-router'
|
||||||
|
import AppLayout from '@/layouts/AppLayout.vue'
|
||||||
|
|
||||||
|
const router = createRouter({
|
||||||
|
history: createWebHistory(),
|
||||||
|
routes: [
|
||||||
|
{ path: '/login', component: () => import('@/views/LoginView.vue'), meta: { public: true } },
|
||||||
|
{
|
||||||
|
path: '/',
|
||||||
|
component: AppLayout,
|
||||||
|
children: [
|
||||||
|
{ path: '', name: 'dashboard', component: () => import('@/views/DashboardView.vue') },
|
||||||
|
{ path: 'scenarios', name: 'scenarios', component: () => import('@/views/ScenariosView.vue') },
|
||||||
|
{ path: 'scenarios/:id', name: 'scenario-detail', component: () => import('@/views/ScenarioDetailView.vue') },
|
||||||
|
{ path: 'sops/:id', name: 'sop-editor', component: () => import('@/views/SOPEditorView.vue') },
|
||||||
|
{ path: 'execute', name: 'execute', component: () => import('@/views/ExecuteView.vue') },
|
||||||
|
{ path: 'runs', name: 'runs', component: () => import('@/views/RunHistoryView.vue') },
|
||||||
|
{ path: 'knowledge', name: 'knowledge', component: () => import('@/views/KnowledgeView.vue') },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
router.beforeEach((to) => {
|
||||||
|
if (!to.meta.public && !localStorage.getItem('access_token')) return '/login'
|
||||||
|
if (to.path === '/login' && localStorage.getItem('access_token')) return '/'
|
||||||
|
})
|
||||||
|
|
||||||
|
export default router
|
||||||
34
codes/web/src/stores/auth.ts
Normal file
34
codes/web/src/stores/auth.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { computed, ref } from 'vue'
|
||||||
|
import { api } from '@/api/client'
|
||||||
|
import type { User } from '@/types'
|
||||||
|
|
||||||
|
interface LoginResult { access_token: string; refresh_token: string; expires_at: string; user: User }
|
||||||
|
|
||||||
|
export const useAuthStore = defineStore('auth', () => {
|
||||||
|
const user = ref<User | null>(null)
|
||||||
|
const token = ref(localStorage.getItem('access_token') || '')
|
||||||
|
const authenticated = computed(() => Boolean(token.value))
|
||||||
|
|
||||||
|
async function login(username: string, password: string) {
|
||||||
|
const result = await api.post<LoginResult>('/auth/login', { username, password })
|
||||||
|
token.value = result.access_token
|
||||||
|
user.value = result.user
|
||||||
|
localStorage.setItem('access_token', result.access_token)
|
||||||
|
localStorage.setItem('refresh_token', result.refresh_token)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadUser() {
|
||||||
|
if (!token.value) return
|
||||||
|
user.value = await api.get<User>('/auth/me')
|
||||||
|
}
|
||||||
|
|
||||||
|
function logout() {
|
||||||
|
token.value = ''
|
||||||
|
user.value = null
|
||||||
|
localStorage.removeItem('access_token')
|
||||||
|
localStorage.removeItem('refresh_token')
|
||||||
|
}
|
||||||
|
|
||||||
|
return { user, token, authenticated, login, loadUser, logout }
|
||||||
|
})
|
||||||
48
codes/web/src/styles/main.css
Normal file
48
codes/web/src/styles/main.css
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
:root {
|
||||||
|
color: #202825;
|
||||||
|
background: #f4f6f5;
|
||||||
|
font-family: "IBM Plex Sans", "Noto Sans SC", "PingFang SC", sans-serif;
|
||||||
|
font-synthesis: none;
|
||||||
|
text-rendering: optimizeLegibility;
|
||||||
|
--ink: #202825;
|
||||||
|
--muted: #69746f;
|
||||||
|
--line: #dce2df;
|
||||||
|
--surface: #ffffff;
|
||||||
|
--canvas: #f4f6f5;
|
||||||
|
--green: #16775b;
|
||||||
|
--green-dark: #0f5843;
|
||||||
|
--coral: #d5644a;
|
||||||
|
--amber: #b97a20;
|
||||||
|
}
|
||||||
|
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
html, body, #app { min-height: 100%; margin: 0; }
|
||||||
|
body { min-width: 320px; }
|
||||||
|
button, input, textarea, select { font: inherit; letter-spacing: 0; }
|
||||||
|
|
||||||
|
.page-shell { max-width: 1440px; margin: 0 auto; padding: 24px 28px 40px; }
|
||||||
|
.page-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 20px; margin-bottom: 22px; }
|
||||||
|
.page-heading h1 { margin: 0; font-family: "Noto Serif SC", "Songti SC", serif; font-size: 26px; line-height: 1.3; font-weight: 700; letter-spacing: 0; }
|
||||||
|
.page-heading p { margin: 6px 0 0; color: var(--muted); line-height: 1.6; }
|
||||||
|
.page-actions { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
|
||||||
|
.surface { background: var(--surface); border: 1px solid var(--line); border-radius: 6px; }
|
||||||
|
.toolbar { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 14px 16px; border-bottom: 1px solid var(--line); }
|
||||||
|
.muted { color: var(--muted); }
|
||||||
|
.empty-copy { max-width: 360px; margin: 0 auto; color: var(--muted); line-height: 1.7; text-align: center; }
|
||||||
|
.status-dot { width: 7px; height: 7px; border-radius: 50%; display: inline-block; margin-right: 7px; background: var(--green); }
|
||||||
|
.ant-btn { border-radius: 5px; box-shadow: none; }
|
||||||
|
.ant-btn-primary { background: var(--green); }
|
||||||
|
.ant-btn-primary:hover { background: var(--green-dark) !important; }
|
||||||
|
.ant-table-wrapper .ant-table { border-radius: 0 0 6px 6px; }
|
||||||
|
.ant-table-wrapper .ant-table-thead > tr > th { color: #52605a; font-size: 12px; font-weight: 650; background: #f7f9f8; }
|
||||||
|
.ant-tag { border-radius: 3px; }
|
||||||
|
.ant-modal-content, .ant-drawer-content { border-radius: 6px !important; }
|
||||||
|
.ant-card { border-radius: 6px; }
|
||||||
|
.ant-input, .ant-input-number, .ant-select-selector, .ant-input-affix-wrapper { border-radius: 4px !important; }
|
||||||
|
|
||||||
|
@media (max-width: 760px) {
|
||||||
|
.page-shell { padding: 18px 14px 28px; }
|
||||||
|
.page-heading { flex-direction: column; }
|
||||||
|
.page-actions { width: 100%; }
|
||||||
|
.page-heading h1 { font-size: 23px; }
|
||||||
|
}
|
||||||
10
codes/web/src/types/index.ts
Normal file
10
codes/web/src/types/index.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
export interface BaseEntity { id: number; created_at: string; updated_at: string }
|
||||||
|
export interface User { user_id: number; tenant_id: number; role_code: string; username: string; display_name: string }
|
||||||
|
export interface Scenario extends BaseEntity { tenant_id: number; name: string; industry: string; role_name: string; goal: string; trigger_text: string; visibility: string; status: string; created_by: number }
|
||||||
|
export interface ScenarioField extends BaseEntity { scenario_id: number; field_key: string; field_name: string; field_type: string; required: boolean; options: unknown[]; validation: Record<string, unknown>; sort_order: number }
|
||||||
|
export interface SOP extends BaseEntity { scenario_id: number; name: string; description: string; status: string }
|
||||||
|
export interface SOPVersion extends BaseEntity { sop_id: number; version: number; status: string; start_node_key: string; published_at?: string }
|
||||||
|
export interface SOPNode extends BaseEntity { node_key: string; type: string; title: string; content: string; config: Record<string, any>; position_x: number; position_y: number }
|
||||||
|
export interface SOPEdge extends BaseEntity { source_node_key: string; target_node_key: string; condition: Record<string, any>; priority: number }
|
||||||
|
export interface SOPRun extends BaseEntity { sop_id: number; sop_version_id: number; current_node_key: string; status: string; answers: Record<string, any>; result: string; started_at: string; completed_at?: string; sop_name?: string }
|
||||||
|
export interface KnowledgeCard extends BaseEntity { scenario_id: number; title: string; status: string; content?: Record<string, any> }
|
||||||
64
codes/web/src/views/DashboardView.vue
Normal file
64
codes/web/src/views/DashboardView.vue
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { onMounted, ref } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { ArrowRightOutlined, AppstoreOutlined, CheckCircleOutlined, PlayCircleOutlined, PlusOutlined } from '@ant-design/icons-vue'
|
||||||
|
import { api } from '@/api/client'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const loading = ref(true)
|
||||||
|
const summary = ref({ scenarios: 0, published_sops: 0, runs: 0, completed_runs: 0 })
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
try { summary.value = await api.get('/dashboard/summary') } finally { loading.value = false }
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="page-shell">
|
||||||
|
<div class="page-heading">
|
||||||
|
<div><h1>今天从经验开始</h1><p>查看知识沉淀和一线执行的最新状态。</p></div>
|
||||||
|
<div class="page-actions"><a-button type="primary" @click="router.push('/scenarios')"><PlusOutlined />创建场景</a-button></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<a-skeleton :loading="loading" active>
|
||||||
|
<section class="metric-strip surface">
|
||||||
|
<div class="metric"><span class="metric-icon green"><AppstoreOutlined /></span><div><b>{{ summary.scenarios }}</b><small>可用场景</small></div></div>
|
||||||
|
<div class="metric"><span class="metric-icon coral"><CheckCircleOutlined /></span><div><b>{{ summary.published_sops }}</b><small>已发布 SOP</small></div></div>
|
||||||
|
<div class="metric"><span class="metric-icon amber"><PlayCircleOutlined /></span><div><b>{{ summary.runs }}</b><small>累计执行</small></div></div>
|
||||||
|
<div class="metric"><span class="completion">{{ summary.runs ? Math.round(summary.completed_runs / summary.runs * 100) : 0 }}%</span><div><b>{{ summary.completed_runs }}</b><small>完成执行</small></div></div>
|
||||||
|
</section>
|
||||||
|
</a-skeleton>
|
||||||
|
|
||||||
|
<section class="work-grid">
|
||||||
|
<div class="surface action-panel">
|
||||||
|
<div class="panel-kicker">常用入口</div>
|
||||||
|
<button @click="router.push('/scenarios')"><span>配置新的业务场景<small>定义字段、目标和触发条件</small></span><ArrowRightOutlined /></button>
|
||||||
|
<button @click="router.push('/execute')"><span>开始执行已发布 SOP<small>根据客户回答逐步推进</small></span><ArrowRightOutlined /></button>
|
||||||
|
<button @click="router.push('/knowledge')"><span>维护审核知识卡<small>统一口径与风险提示</small></span><ArrowRightOutlined /></button>
|
||||||
|
</div>
|
||||||
|
<div class="surface doctrine-panel">
|
||||||
|
<div class="panel-kicker">平台原则</div>
|
||||||
|
<blockquote>一句好话术不应只被收藏,它应该知道何时出现、下一步去哪,以及是否真的有效。</blockquote>
|
||||||
|
<div class="flow-note"><span>创建</span><i></i><span>发布</span><i></i><span>执行</span><i></i><span>复盘</span></div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.metric-strip { display: grid; grid-template-columns: repeat(4, 1fr); overflow: hidden; }
|
||||||
|
.metric { min-height: 116px; display: flex; align-items: center; gap: 16px; padding: 22px; border-right: 1px solid var(--line); }.metric:last-child { border-right: 0; }
|
||||||
|
.metric-icon, .completion { width: 42px; height: 42px; display: grid; place-items: center; border-radius: 5px; font-size: 19px; }.metric-icon.green { background: #e5f3ee; color: #16775b; }.metric-icon.coral { background: #faece7; color: #c4573f; }.metric-icon.amber { background: #f8efdf; color: #a86e1e; }
|
||||||
|
.completion { color: #202825; background: #e8ecea; font-size: 12px; font-weight: 800; }
|
||||||
|
.metric div { display: flex; flex-direction: column; }.metric b { font-family: "Noto Serif SC", serif; font-size: 28px; line-height: 1; }.metric small { margin-top: 8px; color: var(--muted); }
|
||||||
|
.work-grid { display: grid; grid-template-columns: 1.05fr .95fr; gap: 18px; margin-top: 18px; }
|
||||||
|
.action-panel, .doctrine-panel { padding: 22px; }
|
||||||
|
.panel-kicker { margin-bottom: 14px; color: #7c8883; font-size: 11px; font-weight: 700; text-transform: uppercase; }
|
||||||
|
.action-panel button { width: 100%; display: flex; align-items: center; justify-content: space-between; padding: 15px 0; color: var(--ink); text-align: left; background: transparent; border: 0; border-bottom: 1px solid var(--line); cursor: pointer; }.action-panel button:last-child { border-bottom: 0; }.action-panel button:hover { color: var(--green); }
|
||||||
|
.action-panel button span { display: flex; flex-direction: column; font-weight: 650; }.action-panel button small { margin-top: 4px; color: var(--muted); font-weight: 400; }
|
||||||
|
.doctrine-panel { background: #202825; border-color: #202825; color: white; }.doctrine-panel .panel-kicker { color: #89a099; }
|
||||||
|
blockquote { margin: 30px 0 42px; font-family: "Noto Serif SC", serif; font-size: 22px; line-height: 1.65; }
|
||||||
|
.flow-note { display: flex; align-items: center; color: #9cafaa; font-size: 12px; }.flow-note i { flex: 1; height: 1px; margin: 0 10px; background: #4a5a54; }
|
||||||
|
@media (max-width: 900px) { .metric-strip { grid-template-columns: repeat(2,1fr); }.metric:nth-child(2) { border-right: 0; }.metric:nth-child(-n+2) { border-bottom: 1px solid var(--line); }.work-grid { grid-template-columns: 1fr; } }
|
||||||
|
@media (max-width: 520px) { .metric { min-height: 100px; padding: 16px; }.metric b { font-size: 24px; }.metric-icon, .completion { display: none; } }
|
||||||
|
</style>
|
||||||
65
codes/web/src/views/ExecuteView.vue
Normal file
65
codes/web/src/views/ExecuteView.vue
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, reactive, ref } from 'vue'
|
||||||
|
import { CheckCircleOutlined, PlayCircleOutlined, SafetyCertificateOutlined } from '@ant-design/icons-vue'
|
||||||
|
import { message } from 'ant-design-vue'
|
||||||
|
import { api, apiMessage } from '@/api/client'
|
||||||
|
import type { ScenarioField, SOPNode, SOPRun } from '@/types'
|
||||||
|
|
||||||
|
interface PublishedSOP { id:number; name:string; description:string; scenario_id:number; scenario_name:string; version:number }
|
||||||
|
interface RunView { run:SOPRun; node:SOPNode; fields:ScenarioField[] }
|
||||||
|
const loading=ref(true);const sops=ref<PublishedSOP[]>([]);const active=ref<RunView|null>(null);const answers=reactive<Record<string,any>>({});const submitting=ref(false)
|
||||||
|
const currentConfig=computed(()=>active.value?.node.config||{})
|
||||||
|
const currentFields=computed(()=>{if(!active.value)return[];const node=active.value.node;if(node.type==='question'||node.type==='choice')return active.value.fields.filter(f=>f.field_key===currentConfig.value.field_key);if(node.type==='form')return active.value.fields.filter(f=>(currentConfig.value.field_keys||[]).includes(f.field_key));return[]})
|
||||||
|
async function load(){loading.value=true;try{sops.value=(await api.get<{items:PublishedSOP[]}>('/published-sops')).items}catch(error){message.error(apiMessage(error))}finally{loading.value=false}}
|
||||||
|
async function start(sopID:number){try{active.value=await api.post<RunView>('/runs',{sop_id:sopID});Object.keys(answers).forEach(k=>delete answers[k])}catch(error){message.error(apiMessage(error))}}
|
||||||
|
async function next(){if(!active.value)return;submitting.value=true;try{active.value=await api.post<RunView>(`/runs/${active.value.run.id}/answer`,{answers:{...answers}});Object.keys(answers).forEach(k=>delete answers[k])}catch(error){message.error(apiMessage(error))}finally{submitting.value=false}}
|
||||||
|
function inputFor(field:ScenarioField){return field.field_type}
|
||||||
|
function reset(){active.value=null;load()}
|
||||||
|
onMounted(load)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="page-shell execution-shell">
|
||||||
|
<div class="page-heading"><div><h1>执行话术</h1><p>选择已发布 SOP,系统会根据客户回答给出下一步。</p></div></div>
|
||||||
|
<a-spin :spinning="loading">
|
||||||
|
<div v-if="!active" class="sop-catalog">
|
||||||
|
<button v-for="item in sops" :key="item.id" class="sop-entry surface" @click="start(item.id)"><span class="entry-seq">{{ String(item.id).padStart(2,'0') }}</span><div><small>{{ item.scenario_name }} · V{{ item.version }}</small><h2>{{ item.name }}</h2><p>{{ item.description || '按标准步骤执行这套话术流程。' }}</p></div><span class="play"><PlayCircleOutlined /></span></button>
|
||||||
|
<a-empty v-if="!sops.length" description="还没有已发布的 SOP" />
|
||||||
|
</div>
|
||||||
|
<div v-else class="run-workspace">
|
||||||
|
<aside class="run-context">
|
||||||
|
<span class="run-label">RUN-{{ String(active.run.id).padStart(5,'0') }}</span>
|
||||||
|
<h2>执行进行中</h2><p>客户回答会自动保存在当前执行记录中。</p>
|
||||||
|
<div class="context-meta"><span>当前节点</span><b>{{ active.node.title }}</b></div><div class="context-meta"><span>已采集字段</span><b>{{ Object.keys(active.run.answers || {}).length }}</b></div>
|
||||||
|
<div class="privacy-note"><SafetyCertificateOutlined /><span>敏感信息请遵循企业数据规范</span></div>
|
||||||
|
</aside>
|
||||||
|
<main class="conversation surface">
|
||||||
|
<div class="conversation-progress"><span :class="{done:active.run.status==='completed'}"></span><b>{{ active.run.status==='completed'?'流程已完成':'正在执行' }}</b></div>
|
||||||
|
<div class="node-content"><small>{{ active.node.type.toUpperCase() }}</small><h1>{{ active.node.title }}</h1><blockquote v-if="active.node.content">{{ active.node.content }}</blockquote></div>
|
||||||
|
<div v-if="active.run.status==='completed'" class="completed-state"><CheckCircleOutlined /><h3>{{ active.node.type==='escalate'?'已转交处理':'本次执行已完成' }}</h3><p>{{ active.node.content }}</p><a-button type="primary" @click="reset">执行另一套 SOP</a-button></div>
|
||||||
|
<template v-else>
|
||||||
|
<a-form layout="vertical" class="answer-form">
|
||||||
|
<a-form-item v-for="field in currentFields" :key="field.id" :label="field.field_name" :required="field.required">
|
||||||
|
<a-input v-if="inputFor(field)==='text'" v-model:value="answers[field.field_key]" />
|
||||||
|
<a-textarea v-else-if="inputFor(field)==='textarea'" v-model:value="answers[field.field_key]" :rows="3" />
|
||||||
|
<a-input-number v-else-if="inputFor(field)==='number'" v-model:value="answers[field.field_key]" style="width:100%" />
|
||||||
|
<a-radio-group v-else-if="inputFor(field)==='boolean'" v-model:value="answers[field.field_key]"><a-radio :value="true">是</a-radio><a-radio :value="false">否</a-radio></a-radio-group>
|
||||||
|
<a-select v-else-if="inputFor(field)==='select'" v-model:value="answers[field.field_key]" :options="(field.options||[]).map(value=>({value,label:value}))" />
|
||||||
|
<a-select v-else-if="inputFor(field)==='multiselect'" v-model:value="answers[field.field_key]" mode="multiple" :options="(field.options||[]).map(value=>({value,label:value}))" />
|
||||||
|
<a-date-picker v-else-if="inputFor(field)==='date'" v-model:value="answers[field.field_key]" style="width:100%" />
|
||||||
|
<a-input v-else v-model:value="answers[field.field_key]" />
|
||||||
|
</a-form-item>
|
||||||
|
<a-form-item v-if="active.node.type==='choice'&¤tFields.length===0"><a-radio-group v-model:value="answers[currentConfig.field_key]" class="choice-group"><a-radio-button v-for="option in currentConfig.options||[]" :key="option" :value="option">{{ option }}</a-radio-button></a-radio-group></a-form-item>
|
||||||
|
</a-form>
|
||||||
|
<div class="run-actions"><span>{{ currentFields.length ? '填写后继续下一步' : '确认当前话术已完成' }}</span><a-button type="primary" size="large" :loading="submitting" @click="next">继续下一步</a-button></div>
|
||||||
|
</template>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</a-spin>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.execution-shell{max-width:1220px}.sop-catalog{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:14px}.sop-entry{min-height:150px;display:grid;grid-template-columns:40px 1fr 42px;align-items:start;gap:14px;padding:22px;text-align:left;cursor:pointer}.sop-entry:hover{border-color:#9ac8b8;box-shadow:0 8px 24px rgba(32,40,37,.07)}.entry-seq{color:#8c9893;font-family:monospace;font-size:12px}.sop-entry small{color:var(--green);font-weight:650}.sop-entry h2{margin:8px 0 7px;font-family:"Noto Serif SC",serif;font-size:20px}.sop-entry p{margin:0;color:var(--muted);line-height:1.6}.play{width:38px;height:38px;display:grid;place-items:center;color:white;background:#202825;border-radius:4px;font-size:18px}.run-workspace{display:grid;grid-template-columns:270px minmax(0,1fr);gap:18px}.run-context{align-self:start;padding:24px;color:white;background:#202825;border-radius:6px;position:sticky;top:86px}.run-label{color:#72c9ab;font-size:11px;font-weight:700}.run-context h2{margin:12px 0 8px;font-family:"Noto Serif SC",serif}.run-context>p{margin:0 0 28px;color:#aebdb7;line-height:1.6}.context-meta{display:flex;align-items:center;justify-content:space-between;padding:13px 0;border-top:1px solid #3a4742}.context-meta span{color:#9eada7;font-size:12px}.privacy-note{display:flex;gap:8px;margin-top:28px;color:#889b93;font-size:11px}.conversation{min-height:590px;padding:30px 34px}.conversation-progress{display:flex;align-items:center;gap:8px;color:#66736d;font-size:12px}.conversation-progress span{width:8px;height:8px;border-radius:50%;background:#d08736;box-shadow:0 0 0 4px #faefe1}.conversation-progress span.done{background:var(--green);box-shadow:0 0 0 4px #e4f1ec}.node-content{padding:44px 0 26px}.node-content small{color:var(--green);font-size:10px;font-weight:800}.node-content h1{margin:8px 0 22px;font-family:"Noto Serif SC",serif;font-size:28px}.node-content blockquote{margin:0;padding:18px 20px;color:#29342f;background:#f2f6f4;border-left:3px solid var(--green);font-size:17px;line-height:1.8}.answer-form{max-width:680px}.choice-group{display:flex;flex-wrap:wrap}.run-actions{display:flex;align-items:center;justify-content:space-between;gap:16px;margin:24px -34px -30px;padding:18px 34px;border-top:1px solid var(--line);color:var(--muted);font-size:12px}.completed-state{padding:40px 0;text-align:center}.completed-state>span{color:var(--green);font-size:50px}.completed-state h3{margin:14px 0 8px;font-family:"Noto Serif SC",serif;font-size:24px}.completed-state p{margin:0 0 24px;color:var(--muted)}
|
||||||
|
@media(max-width:800px){.sop-catalog{grid-template-columns:1fr}.run-workspace{grid-template-columns:1fr}.run-context{position:static}.conversation{padding:22px}.run-actions{margin:24px -22px -22px;padding:16px 22px}.run-actions span{display:none}}
|
||||||
|
</style>
|
||||||
17
codes/web/src/views/KnowledgeView.vue
Normal file
17
codes/web/src/views/KnowledgeView.vue
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, reactive, ref } from 'vue'
|
||||||
|
import { BookOutlined, DeleteOutlined, PlusOutlined } from '@ant-design/icons-vue'
|
||||||
|
import { message, Modal } from 'ant-design-vue'
|
||||||
|
import { api, apiMessage } from '@/api/client'
|
||||||
|
import type { KnowledgeCard, Scenario } from '@/types'
|
||||||
|
const loading=ref(false);const open=ref(false);const items=ref<KnowledgeCard[]>([]);const scenarios=ref<Scenario[]>([])
|
||||||
|
const form=reactive({scenario_id:undefined as number|undefined,title:'',content:'',forbidden:'',risk_note:''})
|
||||||
|
const scenarioOptions=computed(()=>scenarios.value.map(s=>({value:s.id,label:s.name})))
|
||||||
|
async function load(){loading.value=true;try{const [cards,sceneData]=await Promise.all([api.get<{items:KnowledgeCard[]}>('/knowledge-cards'),api.get<{items:Scenario[]}>('/scenarios')]);items.value=cards.items;scenarios.value=sceneData.items}catch(error){message.error(apiMessage(error))}finally{loading.value=false}}
|
||||||
|
async function create(){try{await api.post('/knowledge-cards',{scenario_id:form.scenario_id,title:form.title,content:{standard_copy:form.content,forbidden_copy:form.forbidden,risk_note:form.risk_note}});message.success('知识卡已发布');open.value=false;Object.assign(form,{scenario_id:undefined,title:'',content:'',forbidden:'',risk_note:''});await load()}catch(error){message.error(apiMessage(error))}}
|
||||||
|
function remove(item:KnowledgeCard){Modal.confirm({title:`归档“${item.title}”?`,okType:'danger',async onOk(){await api.delete(`/knowledge-cards/${item.id}`);await load()}})}
|
||||||
|
function contentOf(item:KnowledgeCard,key:string){return item.content?.[key]||''}
|
||||||
|
onMounted(load)
|
||||||
|
</script>
|
||||||
|
<template><div class="page-shell"><div class="page-heading"><div><h1>知识卡</h1><p>沉淀经过审核的标准表达、禁用语和风险提醒。</p></div><a-button type="primary" @click="open=true"><PlusOutlined />新建知识卡</a-button></div><a-spin :spinning="loading"><div v-if="items.length" class="knowledge-grid"><article v-for="item in items" :key="item.id" class="knowledge-card surface"><header><span><BookOutlined /></span><a-tag color="green">已发布</a-tag></header><h2>{{ item.title }}</h2><p>{{ contentOf(item,'standard_copy')||'暂无标准话术' }}</p><div v-if="contentOf(item,'risk_note')" class="risk-note">{{ contentOf(item,'risk_note') }}</div><footer><span>{{ scenarios.find(s=>s.id===item.scenario_id)?.name||'未分类场景' }}</span><a-button type="text" danger aria-label="归档知识卡" @click="remove(item)"><DeleteOutlined /></a-button></footer></article></div><a-empty v-else description="还没有知识卡" /></a-spin><a-drawer v-model:open="open" title="新建知识卡" width="520"><a-form layout="vertical" :model="form" @finish="create"><a-form-item label="所属场景" name="scenario_id" :rules="[{required:true,message:'请选择场景'}]"><a-select v-model:value="form.scenario_id" :options="scenarioOptions" /></a-form-item><a-form-item label="标题" name="title" :rules="[{required:true,message:'请输入标题'}]"><a-input v-model:value="form.title" /></a-form-item><a-form-item label="标准话术" name="content" :rules="[{required:true,message:'请输入标准话术'}]"><a-textarea v-model:value="form.content" :rows="5" /></a-form-item><a-form-item label="禁用表达"><a-textarea v-model:value="form.forbidden" :rows="3" /></a-form-item><a-form-item label="风险提醒"><a-textarea v-model:value="form.risk_note" :rows="3" /></a-form-item><a-button type="primary" html-type="submit" block>发布知识卡</a-button></a-form></a-drawer></div></template>
|
||||||
|
<style scoped>.knowledge-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:14px}.knowledge-card{min-height:250px;padding:20px;display:flex;flex-direction:column}.knowledge-card header{display:flex;justify-content:space-between}.knowledge-card header>span{width:36px;height:36px;display:grid;place-items:center;color:var(--green);background:#e8f3ef;border-radius:4px}.knowledge-card h2{margin:20px 0 10px;font-family:"Noto Serif SC",serif;font-size:19px}.knowledge-card>p{margin:0;color:#4f5c56;line-height:1.7}.risk-note{margin-top:14px;padding:9px 11px;color:#8b5b16;background:#fbf3e5;border-left:2px solid #c68a37;font-size:12px}.knowledge-card footer{display:flex;align-items:center;justify-content:space-between;margin-top:auto;padding-top:18px;color:var(--muted);font-size:12px}@media(max-width:1050px){.knowledge-grid{grid-template-columns:repeat(2,1fr)}}@media(max-width:650px){.knowledge-grid{grid-template-columns:1fr}}</style>
|
||||||
86
codes/web/src/views/LoginView.vue
Normal file
86
codes/web/src/views/LoginView.vue
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { reactive, ref } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { LockOutlined, UserOutlined } from '@ant-design/icons-vue'
|
||||||
|
import { message } from 'ant-design-vue'
|
||||||
|
import { apiMessage } from '@/api/client'
|
||||||
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const auth = useAuthStore()
|
||||||
|
const loading = ref(false)
|
||||||
|
const form = reactive({ username: 'admin', password: 'admin123' })
|
||||||
|
|
||||||
|
async function submit() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
await auth.login(form.username, form.password)
|
||||||
|
await router.push('/')
|
||||||
|
} catch (error) {
|
||||||
|
message.error(apiMessage(error))
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<main class="login-page">
|
||||||
|
<section class="identity-panel">
|
||||||
|
<div class="identity-grid"></div>
|
||||||
|
<div class="brand-lockup">
|
||||||
|
<div class="brand-symbol"><i></i><i></i><i></i></div>
|
||||||
|
<span>销冠 SOP</span>
|
||||||
|
</div>
|
||||||
|
<div class="identity-copy">
|
||||||
|
<p class="eyebrow">SALES PRACTICE SYSTEM</p>
|
||||||
|
<h1>把经验,变成<br />每天可执行的步骤。</h1>
|
||||||
|
<p>场景配置、话术编排、版本发布与一线执行,在同一个工作台完成。</p>
|
||||||
|
</div>
|
||||||
|
<div class="signal-line"><span>01</span><b></b><span>结构化经验</span></div>
|
||||||
|
</section>
|
||||||
|
<section class="login-panel">
|
||||||
|
<div class="login-form-wrap">
|
||||||
|
<div class="mobile-brand">销冠 SOP</div>
|
||||||
|
<h2>登录工作台</h2>
|
||||||
|
<p>使用企业账号进入经验执行系统</p>
|
||||||
|
<a-form layout="vertical" :model="form" @finish="submit">
|
||||||
|
<a-form-item label="用户名" name="username" :rules="[{ required: true, message: '请输入用户名' }]">
|
||||||
|
<a-input v-model:value="form.username" size="large" autocomplete="username"><template #prefix><UserOutlined /></template></a-input>
|
||||||
|
</a-form-item>
|
||||||
|
<a-form-item label="密码" name="password" :rules="[{ required: true, message: '请输入密码' }]">
|
||||||
|
<a-input-password v-model:value="form.password" size="large" autocomplete="current-password"><template #prefix><LockOutlined /></template></a-input-password>
|
||||||
|
</a-form-item>
|
||||||
|
<a-button type="primary" html-type="submit" size="large" block :loading="loading">进入平台</a-button>
|
||||||
|
</a-form>
|
||||||
|
<div class="login-note"><span></span>本地开发账号已预填</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.login-page { min-height: 100vh; display: grid; grid-template-columns: minmax(420px, 1.05fr) minmax(420px, .95fr); background: #f8faf9; }
|
||||||
|
.identity-panel { position: relative; overflow: hidden; min-height: 640px; padding: 42px 54px; display: flex; flex-direction: column; color: white; background: #202825; }
|
||||||
|
.identity-grid { position: absolute; inset: 0; opacity: .12; background-image: linear-gradient(#b7c4be 1px, transparent 1px), linear-gradient(90deg, #b7c4be 1px, transparent 1px); background-size: 44px 44px; }
|
||||||
|
.brand-lockup { position: relative; display: flex; align-items: center; gap: 12px; font-family: "Noto Serif SC", serif; font-size: 18px; font-weight: 700; }
|
||||||
|
.brand-symbol { width: 34px; height: 34px; display: grid; grid-template-columns: repeat(3,1fr); align-items: end; gap: 3px; padding: 6px; border: 1px solid rgba(255,255,255,.35); border-radius: 5px; }
|
||||||
|
.brand-symbol i { display: block; height: 8px; background: #6cc7a8; }.brand-symbol i:nth-child(2) { height: 15px; }.brand-symbol i:nth-child(3) { height: 22px; background: #ed8e6a; }
|
||||||
|
.identity-copy { position: relative; margin: auto 0; max-width: 600px; }
|
||||||
|
.eyebrow { color: #86d3b8 !important; font-size: 12px; font-weight: 700; }
|
||||||
|
.identity-copy h1 { margin: 20px 0 24px; font-family: "Noto Serif SC", "Songti SC", serif; font-size: clamp(38px, 4.2vw, 64px); line-height: 1.2; letter-spacing: 0; }
|
||||||
|
.identity-copy p { max-width: 510px; color: #bdc8c3; font-size: 16px; line-height: 1.8; }
|
||||||
|
.signal-line { position: relative; display: flex; align-items: center; gap: 12px; color: #91a29b; font-size: 12px; }
|
||||||
|
.signal-line b { width: 70px; height: 1px; background: #52615b; }
|
||||||
|
.login-panel { display: grid; place-items: center; padding: 40px; }
|
||||||
|
.login-form-wrap { width: min(100%, 390px); }
|
||||||
|
.login-form-wrap h2 { margin: 0; color: #202825; font-family: "Noto Serif SC", serif; font-size: 28px; letter-spacing: 0; }
|
||||||
|
.login-form-wrap > p { margin: 8px 0 30px; color: #728079; }
|
||||||
|
.login-form-wrap :deep(.ant-form-item-label label) { color: #46514c; font-weight: 600; }
|
||||||
|
.login-form-wrap :deep(.ant-input-affix-wrapper) { padding: 10px 12px; }
|
||||||
|
.login-form-wrap :deep(.ant-btn-lg) { height: 44px; margin-top: 8px; }
|
||||||
|
.login-note { display: flex; align-items: center; gap: 8px; margin-top: 20px; color: #8a9691; font-size: 12px; }
|
||||||
|
.login-note span { width: 7px; height: 7px; border-radius: 50%; background: #d5644a; }
|
||||||
|
.mobile-brand { display: none; }
|
||||||
|
@media (max-width: 860px) { .login-page { grid-template-columns: 1fr; }.identity-panel { display: none; }.login-panel { min-height: 100vh; padding: 28px; }.mobile-brand { display: block; margin-bottom: 48px; color: #16775b; font-family: "Noto Serif SC", serif; font-weight: 700; } }
|
||||||
|
</style>
|
||||||
12
codes/web/src/views/RunHistoryView.vue
Normal file
12
codes/web/src/views/RunHistoryView.vue
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { onMounted, ref } from 'vue'
|
||||||
|
import { api, apiMessage } from '@/api/client'
|
||||||
|
import { message } from 'ant-design-vue'
|
||||||
|
import type { SOPRun } from '@/types'
|
||||||
|
const loading=ref(false);const items=ref<SOPRun[]>([])
|
||||||
|
async function load(){loading.value=true;try{items.value=(await api.get<{items:SOPRun[]}>('/runs')).items}catch(error){message.error(apiMessage(error))}finally{loading.value=false}}
|
||||||
|
function statusText(v:string){return v==='completed'?'已完成':'进行中'}
|
||||||
|
onMounted(load)
|
||||||
|
</script>
|
||||||
|
<template><div class="page-shell"><div class="page-heading"><div><h1>执行记录</h1><p>查看 SOP 的实际使用情况和执行结果。</p></div></div><section class="surface"><a-table row-key="id" :loading="loading" :data-source="items" :pagination="{pageSize:20}" :columns="[{title:'执行编号',dataIndex:'id',width:130},{title:'SOP',dataIndex:'sop_name'},{title:'状态',dataIndex:'status',width:110},{title:'结果',dataIndex:'result',width:120},{title:'开始时间',dataIndex:'started_at',width:190},{title:'完成时间',dataIndex:'completed_at',width:190}]" :scroll="{x:900}"><template #bodyCell="{column,record}"><template v-if="column.dataIndex==='id'"><code>RUN-{{ String(record.id).padStart(5,'0') }}</code></template><template v-else-if="column.dataIndex==='status'"><a-tag :color="record.status==='completed'?'green':'orange'">{{ statusText(record.status) }}</a-tag></template><template v-else-if="column.dataIndex==='result'">{{ record.result||'-' }}</template><template v-else-if="column.dataIndex==='started_at'||column.dataIndex==='completed_at'">{{ record[column.dataIndex]?new Date(record[column.dataIndex]).toLocaleString('zh-CN'):'-' }}</template></template><template #emptyText><div class="empty-copy">还没有执行记录。发布 SOP 后即可开始执行。</div></template></a-table></section></div></template>
|
||||||
|
<style scoped>code{padding:2px 5px;color:#17654f;background:#eef5f2;border-radius:3px}</style>
|
||||||
83
codes/web/src/views/SOPEditorView.vue
Normal file
83
codes/web/src/views/SOPEditorView.vue
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, reactive, ref } from 'vue'
|
||||||
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
import { ArrowDownOutlined, ArrowLeftOutlined, ArrowUpOutlined, DeleteOutlined, PlusOutlined, SaveOutlined, SendOutlined } from '@ant-design/icons-vue'
|
||||||
|
import { message, Modal } from 'ant-design-vue'
|
||||||
|
import { api, apiMessage } from '@/api/client'
|
||||||
|
import type { SOP, SOPEdge, SOPNode, SOPVersion } from '@/types'
|
||||||
|
|
||||||
|
const route = useRoute(); const router = useRouter(); const id = Number(route.params.id)
|
||||||
|
const loading = ref(true); const saving = ref(false)
|
||||||
|
const sop = ref<SOP | null>(null); const version = ref<SOPVersion | null>(null)
|
||||||
|
const nodes = ref<SOPNode[]>([]); const edges = ref<SOPEdge[]>([]); const selectedKey = ref('')
|
||||||
|
const selected = computed(() => nodes.value.find(n => n.node_key === selectedKey.value))
|
||||||
|
const editable = computed(() => version.value?.status === 'draft')
|
||||||
|
const reviewing = computed(() => version.value?.status === 'reviewing')
|
||||||
|
const nodeTypes = [{value:'message',label:'标准话术'},{value:'question',label:'单项提问'},{value:'choice',label:'选择判断'},{value:'condition',label:'条件节点'},{value:'knowledge',label:'知识卡'},{value:'escalate',label:'转人工/转诊'},{value:'finish',label:'结束'}]
|
||||||
|
const edgeDraft = reactive({ target_node_key: '', field: '', operator: 'equals', value: '' })
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const data = await api.get<{ sop:SOP; version:SOPVersion; nodes:SOPNode[]; edges:SOPEdge[] }>(`/sops/${id}`)
|
||||||
|
sop.value=data.sop; version.value=data.version; nodes.value=data.nodes.map(n=>({...n,config:n.config||{}})); edges.value=data.edges.map(e=>({...e,condition:e.condition||{}})); selectedKey.value ||= nodes.value[0]?.node_key || ''
|
||||||
|
} catch(error) { message.error(apiMessage(error)) } finally { loading.value=false }
|
||||||
|
}
|
||||||
|
function addNode() {
|
||||||
|
const key=`node_${Date.now()}`; nodes.value.push({ id:0,created_at:'',updated_at:'',node_key:key,type:'message',title:'新步骤',content:'',config:{},position_x:0,position_y:nodes.value.length*120 }); selectedKey.value=key
|
||||||
|
}
|
||||||
|
function removeNode(node:SOPNode) {
|
||||||
|
if (['start'].includes(node.type)) return message.warning('开始节点不能删除')
|
||||||
|
Modal.confirm({title:`删除“${node.title}”?`,content:'与该节点关联的转移条件也会删除。',okType:'danger',onOk(){nodes.value=nodes.value.filter(n=>n.node_key!==node.node_key);edges.value=edges.value.filter(e=>e.source_node_key!==node.node_key&&e.target_node_key!==node.node_key);selectedKey.value=nodes.value[0]?.node_key||''}})
|
||||||
|
}
|
||||||
|
function move(index:number,delta:number){const target=index+delta;if(target<0||target>=nodes.value.length)return;const copy=[...nodes.value];[copy[index],copy[target]]=[copy[target],copy[index]];copy.forEach((n,i)=>n.position_y=i*120);nodes.value=copy}
|
||||||
|
function outgoing(key:string){return edges.value.filter(e=>e.source_node_key===key)}
|
||||||
|
function addEdge(){if(!selected.value||!edgeDraft.target_node_key)return;const condition=edgeDraft.field?{field:edgeDraft.field,operator:edgeDraft.operator,value:parseValue(edgeDraft.value)}:{};edges.value.push({id:0,created_at:'',updated_at:'',source_node_key:selected.value.node_key,target_node_key:edgeDraft.target_node_key,condition,priority:outgoing(selected.value.node_key).length});Object.assign(edgeDraft,{target_node_key:'',field:'',operator:'equals',value:''})}
|
||||||
|
function removeEdge(edge:SOPEdge){edges.value=edges.value.filter(e=>e!==edge)}
|
||||||
|
function parseValue(value:string){if(value==='true')return true;if(value==='false')return false;if(value!==''&&!Number.isNaN(Number(value)))return Number(value);return value}
|
||||||
|
function conditionText(condition:Record<string,any>){if(!condition||!condition.field)return '默认路径';const names:Record<string,string>={equals:'等于',not_equals:'不等于',contains:'包含',greater_than:'大于',less_than:'小于',exists:'已填写',not_exists:'未填写'};return `${condition.field} ${names[condition.operator]||condition.operator} ${condition.value ?? ''}`}
|
||||||
|
async function save(){saving.value=true;try{await api.put(`/sops/${id}/draft`,{start_node_key:version.value?.start_node_key||'start',nodes:nodes.value.map(({node_key,type,title,content,config,position_x,position_y})=>({node_key,type,title,content,config,position_x,position_y})),edges:edges.value.map(({source_node_key,target_node_key,condition,priority})=>({source_node_key,target_node_key,condition,priority}))});message.success('草稿已保存');await load()}catch(error){message.error(apiMessage(error))}finally{saving.value=false}}
|
||||||
|
async function validate(){try{const data=await api.post<{valid:boolean;problems:string[]}>(`/sops/${id}/validate`);data.valid?message.success('流程校验通过'):Modal.warning({title:'流程还不能发布',content:data.problems.join(';')})}catch(error){message.error(apiMessage(error))}}
|
||||||
|
async function submitReview(){try{await save();await api.post(`/sops/${id}/submit-review`);message.success('SOP 已提交审核');await load()}catch(error){message.error(apiMessage(error))}}
|
||||||
|
async function publish(){try{if(editable.value) await save();await api.post(`/sops/${id}/publish`);message.success('SOP 已发布');await load()}catch(error){message.error(apiMessage(error))}}
|
||||||
|
async function offline(){try{await api.post(`/sops/${id}/offline`);message.success('SOP 已下线');await load()}catch(error){message.error(apiMessage(error))}}
|
||||||
|
async function createVersion(){try{await api.post(`/sops/${id}/versions`);message.success('已创建新草稿版本');await load()}catch(error){message.error(apiMessage(error))}}
|
||||||
|
onMounted(load)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="editor-page">
|
||||||
|
<header class="editor-header">
|
||||||
|
<div class="editor-title"><a-button type="text" aria-label="返回场景" @click="router.push(`/scenarios/${sop?.scenario_id}`)"><ArrowLeftOutlined /></a-button><div><b>{{ sop?.name || 'SOP 编辑器' }}</b><span>版本 {{ version?.version }} · {{ editable ? '草稿' : (reviewing ? '审核中' : (version?.status === 'offline' ? '已下线' : '已发布')) }}</span></div></div>
|
||||||
|
<div class="page-actions"><a-button @click="validate">校验流程</a-button><a-button v-if="editable" :loading="saving" @click="save"><SaveOutlined />保存</a-button><a-button v-if="editable" @click="submitReview"><SendOutlined />提交审核</a-button><a-button v-if="editable||reviewing" type="primary" @click="publish"><SendOutlined />发布</a-button><a-button v-if="version?.status === 'published'" danger @click="offline">下线</a-button><a-button v-if="!editable&&version?.status !== 'reviewing'&&version?.status !== 'published'" type="primary" @click="createVersion"><PlusOutlined />创建新版本</a-button><a-button v-if="version?.status === 'published'" type="primary" @click="createVersion"><PlusOutlined />创建新版本</a-button></div>
|
||||||
|
</header>
|
||||||
|
<a-spin :spinning="loading">
|
||||||
|
<main class="editor-workbench">
|
||||||
|
<aside class="node-rail surface">
|
||||||
|
<div class="rail-title"><span>流程节点</span><a-button v-if="editable" type="text" aria-label="添加节点" @click="addNode"><PlusOutlined /></a-button></div>
|
||||||
|
<div class="node-list">
|
||||||
|
<button v-for="(node,index) in nodes" :key="node.node_key" :class="{active:selectedKey===node.node_key}" @click="selectedKey=node.node_key"><span class="node-index">{{ String(index+1).padStart(2,'0') }}</span><span class="node-meta"><b>{{ node.title }}</b><small>{{ nodeTypes.find(t=>t.value===node.type)?.label || node.type }}</small></span><span v-if="editable&&node.type!=='start'" class="node-move"><ArrowUpOutlined @click.stop="move(index,-1)"/><ArrowDownOutlined @click.stop="move(index,1)"/></span></button>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
<section v-if="selected" class="node-editor surface">
|
||||||
|
<div class="node-editor-head"><div><span class="node-type-mark">{{ selected.type.toUpperCase() }}</span><h2>{{ selected.title }}</h2></div><a-button v-if="editable&&selected.type!=='start'" danger type="text" @click="removeNode(selected)"><DeleteOutlined />删除</a-button></div>
|
||||||
|
<a-form layout="vertical" class="property-form">
|
||||||
|
<div class="property-grid"><a-form-item label="节点名称"><a-input v-model:value="selected.title" :disabled="!editable" /></a-form-item><a-form-item label="节点类型"><a-select v-model:value="selected.type" :disabled="!editable||selected.type==='start'" :options="nodeTypes" /></a-form-item></div>
|
||||||
|
<a-form-item label="标准话术 / 操作提示"><a-textarea v-model:value="selected.content" :disabled="!editable" :rows="5" placeholder="执行到此节点时展示给一线人员的内容" /></a-form-item>
|
||||||
|
<template v-if="['question','choice'].includes(selected.type)"><div class="property-grid"><a-form-item label="写入字段标识"><a-input v-model:value="selected.config.field_key" :disabled="!editable" placeholder="例如 pet_weight" /></a-form-item><a-form-item label="是否必填"><a-switch v-model:checked="selected.config.required" :disabled="!editable" /></a-form-item></div></template>
|
||||||
|
<a-form-item v-if="selected.type==='choice'" label="可选项(每行一个)"><a-textarea :value="(selected.config.options||[]).join('\n')" :disabled="!editable" :rows="4" @change="selected.config.options=($event.target as HTMLTextAreaElement).value.split('\n').filter(Boolean)" /></a-form-item>
|
||||||
|
</a-form>
|
||||||
|
<div class="transition-head"><div><b>下一步路径</b><span>按优先级匹配,默认路径建议放最后</span></div></div>
|
||||||
|
<div class="transition-list"><div v-for="edge in outgoing(selectedKey)" :key="`${edge.source_node_key}-${edge.target_node_key}-${edge.priority}`" class="transition-row"><span class="route-line"></span><span class="route-target">{{ nodes.find(n=>n.node_key===edge.target_node_key)?.title || edge.target_node_key }}</span><a-tag>{{ conditionText(edge.condition) }}</a-tag><a-button v-if="editable" type="text" danger aria-label="删除路径" @click="removeEdge(edge)"><DeleteOutlined /></a-button></div><div v-if="!outgoing(selectedKey).length" class="route-empty">还没有下一步路径</div></div>
|
||||||
|
<div v-if="editable&&!['finish','escalate'].includes(selected.type)" class="edge-builder"><a-select v-model:value="edgeDraft.target_node_key" placeholder="目标节点" :options="nodes.filter(n=>n.node_key!==selectedKey).map(n=>({value:n.node_key,label:n.title}))" /><a-input v-model:value="edgeDraft.field" placeholder="条件字段(留空为默认)" /><a-select v-model:value="edgeDraft.operator" :options="[{value:'equals',label:'等于'},{value:'not_equals',label:'不等于'},{value:'contains',label:'包含'},{value:'greater_than',label:'大于'},{value:'less_than',label:'小于'},{value:'exists',label:'已填写'}]" /><a-input v-model:value="edgeDraft.value" placeholder="条件值" /><a-button @click="addEdge"><PlusOutlined />添加路径</a-button></div>
|
||||||
|
</section>
|
||||||
|
<a-empty v-else description="请选择一个节点" class="surface editor-empty" />
|
||||||
|
</main>
|
||||||
|
</a-spin>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.editor-page { min-height: calc(100vh - 62px); background: #eef1ef; }.editor-header { min-height: 66px; display:flex;align-items:center;justify-content:space-between;gap:16px;padding:10px 24px;background:white;border-bottom:1px solid var(--line);position:sticky;top:62px;z-index:10 }.editor-title{display:flex;align-items:center;gap:8px}.editor-title>div{display:flex;flex-direction:column}.editor-title b{font-size:15px}.editor-title span{margin-top:3px;color:var(--muted);font-size:11px}.editor-workbench{display:grid;grid-template-columns:280px minmax(0,1fr);gap:16px;max-width:1500px;margin:0 auto;padding:18px}.node-rail{align-self:start;overflow:hidden;position:sticky;top:146px}.rail-title{height:50px;display:flex;align-items:center;justify-content:space-between;padding:0 14px;border-bottom:1px solid var(--line);font-weight:650}.node-list{padding:8px}.node-list button{width:100%;min-height:58px;display:grid;grid-template-columns:34px 1fr auto;align-items:center;gap:9px;padding:7px 8px;text-align:left;background:transparent;border:1px solid transparent;border-radius:4px;cursor:pointer}.node-list button:hover{background:#f5f8f6}.node-list button.active{background:#edf6f2;border-color:#b9d8cc}.node-index{color:#88958f;font-family:monospace;font-size:11px}.node-meta{min-width:0;display:flex;flex-direction:column}.node-meta b{overflow:hidden;font-size:13px;text-overflow:ellipsis;white-space:nowrap}.node-meta small{margin-top:3px;color:var(--muted);font-size:11px}.node-move{display:flex;gap:4px;color:#87938e}.node-move>*:hover{color:var(--green)}.node-editor{align-self:start;min-height:600px;padding:24px}.node-editor-head{display:flex;align-items:flex-start;justify-content:space-between;padding-bottom:18px;border-bottom:1px solid var(--line)}.node-editor-head h2{margin:6px 0 0;font-family:"Noto Serif SC",serif;font-size:22px}.node-type-mark{color:var(--green);font-size:10px;font-weight:750}.property-form{padding-top:22px}.property-grid{display:grid;grid-template-columns:1fr 1fr;gap:16px}.transition-head{display:flex;justify-content:space-between;margin:8px -24px 0;padding:18px 24px 10px;border-top:1px solid var(--line)}.transition-head>div{display:flex;flex-direction:column}.transition-head span{margin-top:4px;color:var(--muted);font-size:11px}.transition-row{min-height:48px;display:grid;grid-template-columns:20px minmax(120px,1fr) auto 36px;align-items:center;gap:10px;border-bottom:1px solid #edf0ee}.route-line{width:16px;height:1px;background:var(--green)}.route-target{font-weight:600}.route-empty{padding:20px 0;color:var(--muted);font-size:12px}.edge-builder{display:grid;grid-template-columns:1.1fr 1fr 120px 1fr auto;gap:8px;padding-top:16px}.editor-empty{min-height:400px;display:grid;place-items:center}
|
||||||
|
@media(max-width:1050px){.edge-builder{grid-template-columns:1fr 1fr}.editor-workbench{grid-template-columns:230px minmax(0,1fr)}}@media(max-width:760px){.editor-header{top:62px;align-items:flex-start;flex-direction:column;padding:12px}.editor-workbench{grid-template-columns:1fr;padding:10px}.node-rail{position:static}.property-grid,.edge-builder{grid-template-columns:1fr}.node-editor{padding:16px}.transition-head{margin:8px -16px 0;padding:16px}}
|
||||||
|
</style>
|
||||||
99
codes/web/src/views/ScenarioDetailView.vue
Normal file
99
codes/web/src/views/ScenarioDetailView.vue
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, reactive, ref } from 'vue'
|
||||||
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
import { ArrowLeftOutlined, BranchesOutlined, DeleteOutlined, PlusOutlined } from '@ant-design/icons-vue'
|
||||||
|
import { message, Modal } from 'ant-design-vue'
|
||||||
|
import { api, apiMessage } from '@/api/client'
|
||||||
|
import type { Scenario, ScenarioField, SOP } from '@/types'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
const scenarioId = Number(route.params.id)
|
||||||
|
const loading = ref(true)
|
||||||
|
const scenario = ref<Scenario | null>(null)
|
||||||
|
const fields = ref<ScenarioField[]>([])
|
||||||
|
const sops = ref<SOP[]>([])
|
||||||
|
const fieldOpen = ref(false)
|
||||||
|
const sopOpen = ref(false)
|
||||||
|
const fieldForm = reactive({ field_key: '', field_name: '', field_type: 'text', required: false, options_text: '', sort_order: 0 })
|
||||||
|
const sopForm = reactive({ name: '', description: '' })
|
||||||
|
const statusColor = computed(() => scenario.value?.status === 'active' ? 'green' : 'default')
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const data = await api.get<{ scenario: Scenario; fields: ScenarioField[]; sops: SOP[] }>(`/scenarios/${scenarioId}`)
|
||||||
|
scenario.value = data.scenario; fields.value = data.fields; sops.value = data.sops
|
||||||
|
} catch (error) { message.error(apiMessage(error)) } finally { loading.value = false }
|
||||||
|
}
|
||||||
|
async function createField() {
|
||||||
|
try {
|
||||||
|
const options = fieldForm.options_text.split('\n').map(v => v.trim()).filter(Boolean)
|
||||||
|
await api.post(`/scenarios/${scenarioId}/fields`, { ...fieldForm, options, validation: {}, sort_order: fields.value.length })
|
||||||
|
message.success('字段已添加'); fieldOpen.value = false
|
||||||
|
Object.assign(fieldForm, { field_key: '', field_name: '', field_type: 'text', required: false, options_text: '', sort_order: 0 }); await load()
|
||||||
|
} catch (error) { message.error(apiMessage(error)) }
|
||||||
|
}
|
||||||
|
async function deleteField(field: ScenarioField) {
|
||||||
|
Modal.confirm({ title: `删除字段“${field.field_name}”?`, content: '已经使用该字段的 SOP 节点可能需要重新配置。', okType: 'danger', async onOk() { await api.delete(`/scenario-fields/${field.id}`); await load() } })
|
||||||
|
}
|
||||||
|
async function createSOP() {
|
||||||
|
try {
|
||||||
|
const data = await api.post<{ sop: SOP }>(`/scenarios/${scenarioId}/sops`, sopForm)
|
||||||
|
message.success('SOP 草稿已创建'); sopOpen.value = false; router.push(`/sops/${data.sop.id}`)
|
||||||
|
} catch (error) { message.error(apiMessage(error)) }
|
||||||
|
}
|
||||||
|
function statusText(value: string) { return ({ draft: '草稿', published: '已发布' } as Record<string,string>)[value] || value }
|
||||||
|
onMounted(load)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="page-shell">
|
||||||
|
<a-button type="link" class="back-link" @click="router.push('/scenarios')"><ArrowLeftOutlined />返回场景列表</a-button>
|
||||||
|
<a-skeleton :loading="loading" active>
|
||||||
|
<div v-if="scenario" class="detail-heading">
|
||||||
|
<div><div class="heading-tags"><a-tag :color="statusColor">{{ scenario.status === 'active' ? '使用中' : '草稿' }}</a-tag><span>{{ scenario.industry }}</span><span>{{ scenario.role_name }}</span></div><h1>{{ scenario.name }}</h1><p>{{ scenario.goal }}</p></div>
|
||||||
|
<div class="scenario-code">SCN-{{ String(scenario.id).padStart(4, '0') }}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<a-tabs default-active-key="fields" class="detail-tabs">
|
||||||
|
<a-tab-pane key="fields" tab="场景字段">
|
||||||
|
<section class="surface">
|
||||||
|
<div class="toolbar"><div><b>采集字段</b><span class="toolbar-note">执行过程中收集的信息</span></div><a-button type="primary" @click="fieldOpen = true"><PlusOutlined />添加字段</a-button></div>
|
||||||
|
<a-table :data-source="fields" row-key="id" :pagination="false" :columns="[{title:'字段名称',dataIndex:'field_name'},{title:'字段标识',dataIndex:'field_key'},{title:'类型',dataIndex:'field_type',width:130},{title:'必填',dataIndex:'required',width:90},{title:'',key:'action',width:70}]">
|
||||||
|
<template #bodyCell="{ column, record }"><template v-if="column.dataIndex === 'field_key'"><code>{{ record.field_key }}</code></template><template v-else-if="column.dataIndex === 'required'"><a-tag :color="record.required ? 'orange' : 'default'">{{ record.required ? '必填' : '选填' }}</a-tag></template><template v-else-if="column.key === 'action'"><a-button type="text" danger aria-label="删除字段" @click="deleteField(record)"><DeleteOutlined /></a-button></template></template>
|
||||||
|
<template #emptyText><div class="empty-copy">还没有字段。先添加执行过程中需要收集的客户信息。</div></template>
|
||||||
|
</a-table>
|
||||||
|
</section>
|
||||||
|
</a-tab-pane>
|
||||||
|
<a-tab-pane key="sops" tab="SOP 流程">
|
||||||
|
<section class="surface">
|
||||||
|
<div class="toolbar"><div><b>话术流程</b><span class="toolbar-note">一个场景可以包含多套 SOP</span></div><a-button type="primary" @click="sopOpen = true"><PlusOutlined />创建 SOP</a-button></div>
|
||||||
|
<div v-if="sops.length" class="sop-list">
|
||||||
|
<button v-for="item in sops" :key="item.id" @click="router.push(`/sops/${item.id}`)"><span class="sop-icon"><BranchesOutlined /></span><span class="sop-copy"><b>{{ item.name }}</b><small>{{ item.description || '暂无说明' }}</small></span><a-tag :color="item.status === 'published' ? 'green' : 'default'">{{ statusText(item.status) }}</a-tag><span class="sop-date">{{ new Date(item.updated_at).toLocaleDateString('zh-CN') }}</span></button>
|
||||||
|
</div>
|
||||||
|
<div v-else class="empty-state"><BranchesOutlined /><p>还没有 SOP。创建一套流程,将经验变成连续动作。</p></div>
|
||||||
|
</section>
|
||||||
|
</a-tab-pane>
|
||||||
|
</a-tabs>
|
||||||
|
</a-skeleton>
|
||||||
|
|
||||||
|
<a-modal v-model:open="fieldOpen" title="添加场景字段" ok-text="添加字段" @ok="createField">
|
||||||
|
<a-form layout="vertical" :model="fieldForm">
|
||||||
|
<div class="field-grid"><a-form-item label="字段名称"><a-input v-model:value="fieldForm.field_name" placeholder="宠物体重" /></a-form-item><a-form-item label="字段标识"><a-input v-model:value="fieldForm.field_key" placeholder="pet_weight" /></a-form-item></div>
|
||||||
|
<a-form-item label="字段类型"><a-select v-model:value="fieldForm.field_type" :options="[{value:'text',label:'单行文本'},{value:'textarea',label:'多行文本'},{value:'number',label:'数字'},{value:'boolean',label:'是/否'},{value:'select',label:'单选'},{value:'multiselect',label:'多选'},{value:'date',label:'日期'}]" /></a-form-item>
|
||||||
|
<a-form-item v-if="['select','multiselect'].includes(fieldForm.field_type)" label="选项(每行一个)"><a-textarea v-model:value="fieldForm.options_text" :rows="4" /></a-form-item>
|
||||||
|
<a-checkbox v-model:checked="fieldForm.required">执行时必须填写</a-checkbox>
|
||||||
|
</a-form>
|
||||||
|
</a-modal>
|
||||||
|
<a-modal v-model:open="sopOpen" title="创建 SOP 草稿" ok-text="创建并编排" @ok="createSOP">
|
||||||
|
<a-form layout="vertical" :model="sopForm"><a-form-item label="SOP 名称"><a-input v-model:value="sopForm.name" placeholder="问诊问药标准流程" /></a-form-item><a-form-item label="说明"><a-textarea v-model:value="sopForm.description" :rows="4" placeholder="这套流程适用于什么情况" /></a-form-item></a-form>
|
||||||
|
</a-modal>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.back-link { margin: -4px 0 12px -14px; color: var(--muted); }.detail-heading { min-height: 150px; display: flex; align-items: flex-end; justify-content: space-between; gap: 24px; padding: 28px; color: white; background: #202825; border-radius: 6px; }.detail-heading h1 { margin: 10px 0 8px; font-family: "Noto Serif SC",serif; font-size: 28px; letter-spacing: 0; }.detail-heading p { max-width: 720px; margin: 0; color: #b8c4bf; }.heading-tags { display: flex; align-items: center; gap: 10px; color: #a8b6b0; font-size: 12px; }.scenario-code { align-self: flex-start; color: #70817a; font-size: 12px; }.detail-tabs { margin-top: 18px; }.toolbar > div { display: flex; align-items: baseline; gap: 12px; }.toolbar-note { color: var(--muted); font-size: 12px; }.field-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; }code { padding: 2px 5px; color: #17654f; background: #eef5f2; border-radius: 3px; }
|
||||||
|
.sop-list button { width: 100%; min-height: 74px; display: grid; grid-template-columns: 42px 1fr auto 100px; align-items: center; gap: 14px; padding: 12px 18px; text-align: left; background: white; border: 0; border-bottom: 1px solid var(--line); cursor: pointer; }.sop-list button:hover { background: #f8faf9; }.sop-icon { width: 38px; height: 38px; display: grid; place-items: center; color: var(--green); background: #e8f3ef; border-radius: 4px; }.sop-copy { min-width: 0; display: flex; flex-direction: column; }.sop-copy small { overflow: hidden; margin-top: 4px; color: var(--muted); text-overflow: ellipsis; white-space: nowrap; }.sop-date { color: var(--muted); font-size: 12px; text-align: right; }.empty-state { padding: 64px 20px; color: #82908a; text-align: center; }.empty-state > span { font-size: 32px; }.empty-state p { margin: 12px 0 0; }
|
||||||
|
@media (max-width: 650px) { .detail-heading { align-items: flex-start; flex-direction: column; }.scenario-code { display:none; }.field-grid { grid-template-columns: 1fr; gap: 0; }.sop-list button { grid-template-columns: 36px 1fr auto; }.sop-date { display:none; } }
|
||||||
|
</style>
|
||||||
77
codes/web/src/views/ScenariosView.vue
Normal file
77
codes/web/src/views/ScenariosView.vue
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { onMounted, reactive, ref } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { PlusOutlined, SearchOutlined } from '@ant-design/icons-vue'
|
||||||
|
import { message } from 'ant-design-vue'
|
||||||
|
import { api, apiMessage } from '@/api/client'
|
||||||
|
import type { Scenario } from '@/types'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const loading = ref(false)
|
||||||
|
const open = ref(false)
|
||||||
|
const keyword = ref('')
|
||||||
|
const items = ref<Scenario[]>([])
|
||||||
|
const form = reactive({ name: '', industry: '', role_name: '', goal: '', trigger_text: '', visibility: 'tenant' })
|
||||||
|
const columns = [
|
||||||
|
{ title: '场景名称', dataIndex: 'name', key: 'name' }, { title: '行业', dataIndex: 'industry', key: 'industry', width: 140 },
|
||||||
|
{ title: '适用角色', dataIndex: 'role_name', key: 'role_name', width: 160 }, { title: '状态', dataIndex: 'status', key: 'status', width: 110 },
|
||||||
|
{ title: '更新时间', dataIndex: 'updated_at', key: 'updated_at', width: 180 }, { title: '', key: 'action', width: 80 },
|
||||||
|
]
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
loading.value = true
|
||||||
|
try { items.value = (await api.get<{ items: Scenario[] }>('/scenarios', { params: { keyword: keyword.value } })).items } finally { loading.value = false }
|
||||||
|
}
|
||||||
|
async function create() {
|
||||||
|
try {
|
||||||
|
const item = await api.post<Scenario>('/scenarios', form)
|
||||||
|
message.success('场景已创建')
|
||||||
|
open.value = false
|
||||||
|
router.push(`/scenarios/${item.id}`)
|
||||||
|
} catch (error) { message.error(apiMessage(error)) }
|
||||||
|
}
|
||||||
|
function statusText(value: string) { return ({ draft: '草稿', active: '使用中', archived: '已归档' } as Record<string,string>)[value] || value }
|
||||||
|
onMounted(load)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="page-shell">
|
||||||
|
<div class="page-heading">
|
||||||
|
<div><h1>场景与 SOP</h1><p>从业务触发点开始,定义信息字段与可执行话术流程。</p></div>
|
||||||
|
<a-button type="primary" @click="open = true"><PlusOutlined />新建场景</a-button>
|
||||||
|
</div>
|
||||||
|
<section class="surface">
|
||||||
|
<div class="toolbar">
|
||||||
|
<a-input v-model:value="keyword" allow-clear placeholder="搜索名称或行业" style="width: 280px" @press-enter="load"><template #prefix><SearchOutlined /></template></a-input>
|
||||||
|
<span class="muted">共 {{ items.length }} 个场景</span>
|
||||||
|
</div>
|
||||||
|
<a-table :columns="columns" :data-source="items" :loading="loading" row-key="id" :pagination="false" :scroll="{ x: 800 }" :custom-row="(record: Scenario) => ({ onClick: () => router.push(`/scenarios/${record.id}`) })">
|
||||||
|
<template #bodyCell="{ column, record }">
|
||||||
|
<template v-if="column.key === 'name'"><div class="scenario-name"><b>{{ record.name }}</b><small>{{ record.goal }}</small></div></template>
|
||||||
|
<template v-else-if="column.key === 'status'"><a-tag :color="record.status === 'active' ? 'green' : 'default'">{{ statusText(record.status) }}</a-tag></template>
|
||||||
|
<template v-else-if="column.key === 'updated_at'">{{ new Date(record.updated_at).toLocaleString('zh-CN') }}</template>
|
||||||
|
<template v-else-if="column.key === 'action'"><a-button type="link" size="small">配置</a-button></template>
|
||||||
|
</template>
|
||||||
|
<template #emptyText><div class="empty-copy">还没有业务场景。创建第一个场景后,就可以配置字段和 SOP。</div></template>
|
||||||
|
</a-table>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<a-drawer v-model:open="open" title="创建业务场景" width="520" :destroy-on-close="true">
|
||||||
|
<a-form layout="vertical" :model="form">
|
||||||
|
<a-form-item label="场景名称" name="name" :rules="[{ required: true, message: '请输入场景名称' }]"><a-input v-model:value="form.name" placeholder="例如:宠物医生问诊问药" /></a-form-item>
|
||||||
|
<div class="form-grid"><a-form-item label="所属行业" name="industry" :rules="[{ required: true, message: '请输入行业' }]"><a-input v-model:value="form.industry" placeholder="宠物医疗" /></a-form-item><a-form-item label="适用角色" name="role_name" :rules="[{ required: true, message: '请输入角色' }]"><a-input v-model:value="form.role_name" placeholder="医生、客服" /></a-form-item></div>
|
||||||
|
<a-form-item label="执行目标" name="goal" :rules="[{ required: true, message: '请输入执行目标' }]"><a-textarea v-model:value="form.goal" :rows="3" placeholder="这个场景最终要推动什么结果" /></a-form-item>
|
||||||
|
<a-form-item label="触发条件" name="trigger_text" :rules="[{ required: true, message: '请输入触发条件' }]"><a-textarea v-model:value="form.trigger_text" :rows="3" placeholder="什么时候进入这个场景" /></a-form-item>
|
||||||
|
<a-form-item label="可见范围"><a-radio-group v-model:value="form.visibility"><a-radio value="tenant">全企业</a-radio><a-radio value="team">团队</a-radio><a-radio value="private">仅自己</a-radio></a-radio-group></a-form-item>
|
||||||
|
<a-button type="primary" block @click="create">创建并继续配置</a-button>
|
||||||
|
</a-form>
|
||||||
|
</a-drawer>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.scenario-name { max-width: 460px; display: flex; flex-direction: column; cursor: pointer; }.scenario-name b { font-size: 14px; }.scenario-name small { overflow: hidden; margin-top: 4px; color: var(--muted); text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; }
|
||||||
|
:deep(.ant-table-row) { cursor: pointer; }
|
||||||
|
@media (max-width: 600px) { .form-grid { grid-template-columns: 1fr; gap: 0; } }
|
||||||
|
</style>
|
||||||
19
codes/web/tsconfig.json
Normal file
19
codes/web/tsconfig.json
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"strict": true,
|
||||||
|
"jsx": "preserve",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": { "@/*": ["src/*"] },
|
||||||
|
"types": ["vite/client", "node"]
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.vue", "vite.config.ts"]
|
||||||
|
}
|
||||||
19
codes/web/vite.config.ts
Normal file
19
codes/web/vite.config.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import vue from '@vitejs/plugin-vue'
|
||||||
|
import { fileURLToPath, URL } from 'node:url'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [vue()],
|
||||||
|
resolve: {
|
||||||
|
alias: { '@': fileURLToPath(new URL('./src', import.meta.url)) },
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
host: '127.0.0.1',
|
||||||
|
port: 5173,
|
||||||
|
proxy: { '/api': 'http://127.0.0.1:8080' },
|
||||||
|
},
|
||||||
|
build: {
|
||||||
|
outDir: 'dist',
|
||||||
|
sourcemap: false,
|
||||||
|
},
|
||||||
|
})
|
||||||
493
docs/technical-implementation.md
Normal file
493
docs/technical-implementation.md
Normal file
@@ -0,0 +1,493 @@
|
|||||||
|
# 销冠平台技术实现方案
|
||||||
|
|
||||||
|
## 1. 文档范围
|
||||||
|
|
||||||
|
本文档用于指导“场景化销售话术与 SOP 平台”的第一阶段实现。
|
||||||
|
|
||||||
|
第一阶段的核心闭环是:
|
||||||
|
|
||||||
|
```text
|
||||||
|
创建场景 -> 配置自定义字段 -> 编排 SOP -> 审核发布 -> 一线人员执行 -> 记录反馈
|
||||||
|
```
|
||||||
|
|
||||||
|
场景不写死在代码中。宠物医生问诊问药只是一个配置示例,后续可以创建保险咨询、房地产销售、教育课程顾问等其他场景。
|
||||||
|
|
||||||
|
本阶段暂不实现文件存储、AI 自动生成、CRM 集成和微服务拆分。
|
||||||
|
|
||||||
|
## 2. 技术选型
|
||||||
|
|
||||||
|
### 2.1 后端
|
||||||
|
|
||||||
|
- Go:业务服务和流程执行引擎
|
||||||
|
- Gin:HTTP 路由、中间件和请求处理
|
||||||
|
- Zap:结构化日志
|
||||||
|
- cleanenv:YAML 配置解析和环境变量覆盖
|
||||||
|
- GORM:MySQL 数据访问
|
||||||
|
- golang-migrate:数据库迁移
|
||||||
|
- `go-playground/validator`:请求参数校验
|
||||||
|
- JWT + Refresh Token:登录认证
|
||||||
|
|
||||||
|
第一阶段采用模块化单体。所有业务模块运行在一个 Go 服务中,通过清晰的内部边界保持可维护性,后续再根据实际负载拆分服务。
|
||||||
|
|
||||||
|
配置加载使用 `github.com/ilyakaznacheev/cleanenv`。它的行为简单明确:先读取 YAML 文件,再读取环境变量,环境变量值覆盖文件中的同名配置。
|
||||||
|
|
||||||
|
### 2.2 前端
|
||||||
|
|
||||||
|
- Vue 3
|
||||||
|
- Vite
|
||||||
|
- TypeScript
|
||||||
|
- Ant Design Vue
|
||||||
|
- Vue Router
|
||||||
|
- Pinia
|
||||||
|
- Axios
|
||||||
|
|
||||||
|
SOP 编辑器第一版使用步骤卡片和条件配置,不立即引入复杂的画布编辑器。需要图形化节点编排时,使用 Vue 生态的流程图组件,不使用 React Flow。
|
||||||
|
|
||||||
|
### 2.3 数据库和运行环境
|
||||||
|
|
||||||
|
- MySQL 8.0+
|
||||||
|
- Docker Compose:本地开发
|
||||||
|
- Nginx:生产环境反向代理
|
||||||
|
- Linux 容器部署
|
||||||
|
|
||||||
|
本阶段不引入 Redis。登录会话、场景配置和执行状态先使用 MySQL;只有出现异步任务、缓存或高并发需求时再增加 Redis。
|
||||||
|
|
||||||
|
## 3. 配置管理
|
||||||
|
|
||||||
|
配置结构统一放在 `internal/config/`,业务代码不能直接读取环境变量。
|
||||||
|
|
||||||
|
配置优先级如下:
|
||||||
|
|
||||||
|
```text
|
||||||
|
结构体默认值 < config.yml < 当前环境配置文件 < 环境变量
|
||||||
|
```
|
||||||
|
|
||||||
|
配置文件约定如下:
|
||||||
|
|
||||||
|
```text
|
||||||
|
configs/config.example.yml # 配置模板,可提交,不包含敏感信息
|
||||||
|
configs/config.yml # 基础配置
|
||||||
|
configs/config.test.yml # 测试环境覆盖配置
|
||||||
|
configs/config.prod.yml # 生产环境覆盖配置
|
||||||
|
```
|
||||||
|
|
||||||
|
运行环境通过 `APP_ENV` 或启动参数 `--env` 选择,优先级为启动参数高于环境变量:
|
||||||
|
|
||||||
|
```text
|
||||||
|
未指定环境 -> config.yml
|
||||||
|
APP_ENV=test -> config.yml + config.test.yml
|
||||||
|
APP_ENV=prod -> config.yml + config.prod.yml
|
||||||
|
```
|
||||||
|
|
||||||
|
`config.yml` 作为基础文件必须存在。`test` 和 `prod` 环境对应的覆盖文件也必须存在;缺失时服务直接退出。环境配置文件只填写与基础配置不同的字段,未填写的字段沿用 `config.yml`。
|
||||||
|
|
||||||
|
配置文件路径通过 `--config-dir` 指定,默认使用当前工作目录下的 `configs/`。部署时可以挂载外部配置目录,不需要把真实生产配置提交到代码仓库。
|
||||||
|
|
||||||
|
环境变量名称通过结构体标签显式声明,例如:
|
||||||
|
|
||||||
|
```text
|
||||||
|
APP_SERVER_HOST
|
||||||
|
APP_SERVER_PORT
|
||||||
|
APP_DATABASE_HOST
|
||||||
|
APP_DATABASE_PORT
|
||||||
|
APP_DATABASE_USER
|
||||||
|
APP_DATABASE_PASSWORD
|
||||||
|
APP_DATABASE_NAME
|
||||||
|
```
|
||||||
|
|
||||||
|
配置字段约定:
|
||||||
|
|
||||||
|
- `yaml`:YAML 文件中的字段名。
|
||||||
|
- `env`:环境变量名称,必须显式声明。
|
||||||
|
- `env-default`:没有文件值和环境变量时使用的默认值。
|
||||||
|
- `env-required:"true"`:必须由环境变量提供的敏感或关键配置。
|
||||||
|
- `env-prefix`:为嵌套配置统一增加环境变量前缀。
|
||||||
|
- `env-layout`:日期、时间等特殊类型的解析格式。
|
||||||
|
|
||||||
|
生产环境中,数据库密码、JWT 密钥等敏感字段必须使用 `env-required:"true"`,不写入 YAML 文件。配置加载失败或必填项缺失时,服务直接退出,不使用不完整配置继续启动。
|
||||||
|
|
||||||
|
加载过程必须保持以下顺序:先加载基础配置,再加载当前环境配置,最后执行环境变量覆盖。环境变量不能被后续 YAML 文件覆盖。
|
||||||
|
|
||||||
|
## 4. 目录规划
|
||||||
|
|
||||||
|
代码统一放在 `codes/` 目录下。按照 `golang-standards/project-layout` 的组织方式,同时遵循“根目录放置 main.go”和“前端放在 web 目录”的项目约束。
|
||||||
|
|
||||||
|
```text
|
||||||
|
codes/
|
||||||
|
├── main.go # Go 服务入口,位于代码根目录
|
||||||
|
├── go.mod
|
||||||
|
├── go.sum
|
||||||
|
├── internal/
|
||||||
|
│ ├── config/ # 配置加载
|
||||||
|
│ ├── logger/ # Zap 初始化和日志字段规范
|
||||||
|
│ ├── middleware/ # 认证、租户、请求日志、异常恢复
|
||||||
|
│ ├── router/ # 路由注册
|
||||||
|
│ ├── handler/ # HTTP 接口层
|
||||||
|
│ ├── service/ # 业务服务层
|
||||||
|
│ ├── repository/ # 数据访问层
|
||||||
|
│ ├── model/ # 数据库模型和请求响应模型
|
||||||
|
│ ├── engine/ # SOP 流程执行引擎
|
||||||
|
│ └── webassets/ # 前端嵌入和静态资源处理
|
||||||
|
├── pkg/
|
||||||
|
│ └── response/ # 可被外部复用的通用响应结构
|
||||||
|
├── configs/
|
||||||
|
│ ├── config.yml
|
||||||
|
│ ├── config.test.yml
|
||||||
|
│ ├── config.prod.yml
|
||||||
|
│ └── config.example.yml
|
||||||
|
├── migrations/ # MySQL 数据库迁移文件
|
||||||
|
├── scripts/ # 构建、检查和发布脚本
|
||||||
|
└── web/
|
||||||
|
├── package.json
|
||||||
|
├── vite.config.ts
|
||||||
|
├── index.html
|
||||||
|
├── src/
|
||||||
|
│ ├── api/
|
||||||
|
│ ├── components/
|
||||||
|
│ ├── layouts/
|
||||||
|
│ ├── router/
|
||||||
|
│ ├── stores/
|
||||||
|
│ ├── types/
|
||||||
|
│ └── views/
|
||||||
|
└── dist/ # 前端构建产物,由 Go 使用 go:embed 嵌入
|
||||||
|
```
|
||||||
|
|
||||||
|
说明:`cmd/` 目录不使用,Go 入口固定为 `codes/main.go`。`codes/web/dist/` 是构建产物目录,前端构建完成后由 Go 服务作为静态资源提供。
|
||||||
|
|
||||||
|
```text
|
||||||
|
浏览器
|
||||||
|
├── 管理端:场景、字段、SOP、知识卡、审核
|
||||||
|
└── 执行端:按 SOP 执行和记录客户回答
|
||||||
|
|
|
||||||
|
v
|
||||||
|
Go HTTP 服务
|
||||||
|
├── 身份认证与租户权限
|
||||||
|
├── 场景配置服务
|
||||||
|
├── SOP 版本服务
|
||||||
|
├── 流程执行引擎
|
||||||
|
├── 知识卡服务
|
||||||
|
└── 执行记录与统计
|
||||||
|
|
|
||||||
|
v
|
||||||
|
MySQL
|
||||||
|
```
|
||||||
|
|
||||||
|
## 5. 系统架构
|
||||||
|
|
||||||
|
Go 服务同时承担 API 服务和前端静态资源服务。前端构建后通过 `go:embed` 打入 Go 二进制,生产环境只需要部署一个服务包和配置文件。
|
||||||
|
|
||||||
|
## 6. go:embed 方案
|
||||||
|
|
||||||
|
前端构建输出到 `codes/web/dist/`,Go 服务通过 `go:embed` 嵌入该目录。
|
||||||
|
|
||||||
|
构建流程为:
|
||||||
|
|
||||||
|
```text
|
||||||
|
进入 codes/web -> 安装依赖 -> 执行前端构建 -> 生成 web/dist
|
||||||
|
-> 回到 codes -> 执行 Go 构建 -> 生成包含前端资源的二进制
|
||||||
|
```
|
||||||
|
|
||||||
|
服务端静态资源处理规则:
|
||||||
|
|
||||||
|
- `/api/` 路径只进入 Go API 路由。
|
||||||
|
- 静态文件路径优先读取 `web/dist` 中对应文件。
|
||||||
|
- 非 API 且找不到文件的路径回退到 `index.html`,支持 Vue Router 的 history 模式。
|
||||||
|
- 前端资源由 Nginx 或应用服务设置长期缓存,`index.html` 不使用长期缓存。
|
||||||
|
|
||||||
|
前端 `dist` 不存在时,Go 项目不能完成编译,因此代码仓库需要保留一个可构建的前端产物占位文件,或者在 CI 中强制先执行前端构建。
|
||||||
|
|
||||||
|
## 7. 场景模型
|
||||||
|
|
||||||
|
场景是平台的一级可配置对象,不能在代码中写死行业字段。
|
||||||
|
|
||||||
|
一个场景至少包含:
|
||||||
|
|
||||||
|
- 场景名称
|
||||||
|
- 所属行业
|
||||||
|
- 适用角色
|
||||||
|
- 使用目标
|
||||||
|
- 触发条件
|
||||||
|
- 可见范围
|
||||||
|
- 自定义字段
|
||||||
|
- 一个或多个 SOP
|
||||||
|
- 关联知识卡
|
||||||
|
- 发布状态
|
||||||
|
|
||||||
|
例如宠物医生问诊问药场景可以配置:
|
||||||
|
|
||||||
|
```text
|
||||||
|
pet_type 宠物种类
|
||||||
|
pet_age 年龄
|
||||||
|
pet_weight 体重
|
||||||
|
symptom 症状
|
||||||
|
symptom_duration 症状持续时间
|
||||||
|
has_emergency_sign 是否存在急症
|
||||||
|
```
|
||||||
|
|
||||||
|
保险咨询场景则可以配置客户年龄、职业、预算和保险类型。新增场景只需要增加配置数据,不需要修改业务代码。
|
||||||
|
|
||||||
|
## 8. SOP 模型
|
||||||
|
|
||||||
|
SOP 由版本、节点和连线组成。
|
||||||
|
|
||||||
|
第一阶段支持以下节点类型:
|
||||||
|
|
||||||
|
- `start`:流程开始
|
||||||
|
- `message`:展示标准话术
|
||||||
|
- `question`:提问并写入一个场景字段
|
||||||
|
- `form`:一次收集多个字段
|
||||||
|
- `choice`:让执行人员选择客户回答
|
||||||
|
- `condition`:根据已收集字段进行分支
|
||||||
|
- `knowledge`:展示已审核知识卡
|
||||||
|
- `escalate`:转人工、转医生或线下处理
|
||||||
|
- `finish`:结束流程
|
||||||
|
|
||||||
|
条件必须保存为结构化规则,不能让用户输入或执行任意 JavaScript。条件规则支持等于、不等于、包含、大于、小于、全部满足、任一满足等操作。
|
||||||
|
|
||||||
|
发布前需要校验:
|
||||||
|
|
||||||
|
- 只有一个开始节点
|
||||||
|
- 所有节点都可从开始节点访问
|
||||||
|
- 所有分支都有出口
|
||||||
|
- 不存在无法结束的路径
|
||||||
|
- 必填字段存在对应采集节点
|
||||||
|
- 关联知识卡已经审核
|
||||||
|
- 急症或高风险分支有明确的转人工或转诊动作
|
||||||
|
|
||||||
|
## 9. MySQL 数据表
|
||||||
|
|
||||||
|
核心表如下:
|
||||||
|
|
||||||
|
```text
|
||||||
|
users 用户
|
||||||
|
tenants 企业或组织
|
||||||
|
tenant_members 企业成员和角色
|
||||||
|
roles 角色权限
|
||||||
|
|
||||||
|
scenarios 场景
|
||||||
|
scenario_fields 场景自定义字段
|
||||||
|
|
||||||
|
sops SOP 基础信息
|
||||||
|
sop_versions SOP 版本
|
||||||
|
sop_nodes SOP 节点
|
||||||
|
sop_edges SOP 连线和条件
|
||||||
|
|
||||||
|
knowledge_cards 话术或知识卡
|
||||||
|
knowledge_card_versions 知识卡版本
|
||||||
|
|
||||||
|
sop_runs SOP 执行实例
|
||||||
|
sop_run_events SOP 节点执行事件
|
||||||
|
sop_feedback 执行反馈
|
||||||
|
audit_logs 操作审计
|
||||||
|
```
|
||||||
|
|
||||||
|
关键设计:
|
||||||
|
|
||||||
|
- 所有租户相关数据必须能追溯到 `tenant_id`。
|
||||||
|
- 场景字段使用行记录保存,避免把场景字段写死在表结构中。
|
||||||
|
- 节点配置和条件规则使用 MySQL JSON 字段保存。
|
||||||
|
- 已发布版本不可直接修改,修改时复制为新的草稿版本。
|
||||||
|
- 版本状态流转为 `draft -> reviewing -> published -> offline`;管理员也可以从草稿直接发布。
|
||||||
|
- 执行过程保存事件记录,便于复盘和统计。
|
||||||
|
- 需要参与筛选和统计的字段不能只存 JSON,应在事件表或统计表中建立结构化字段。
|
||||||
|
|
||||||
|
## 10. 后端接口规划
|
||||||
|
|
||||||
|
### 场景接口
|
||||||
|
|
||||||
|
```text
|
||||||
|
POST /api/v1/scenarios
|
||||||
|
GET /api/v1/scenarios
|
||||||
|
GET /api/v1/scenarios/:id
|
||||||
|
PUT /api/v1/scenarios/:id
|
||||||
|
DELETE /api/v1/scenarios/:id
|
||||||
|
```
|
||||||
|
|
||||||
|
### 自定义字段接口
|
||||||
|
|
||||||
|
```text
|
||||||
|
POST /api/v1/scenarios/:id/fields
|
||||||
|
PUT /api/v1/scenario-fields/:id
|
||||||
|
DELETE /api/v1/scenario-fields/:id
|
||||||
|
```
|
||||||
|
|
||||||
|
### SOP 接口
|
||||||
|
|
||||||
|
```text
|
||||||
|
POST /api/v1/scenarios/:id/sops
|
||||||
|
GET /api/v1/scenarios/:id/sops
|
||||||
|
PUT /api/v1/sops/:id/draft
|
||||||
|
POST /api/v1/sops/:id/validate
|
||||||
|
POST /api/v1/sops/:id/submit-review
|
||||||
|
POST /api/v1/sops/:id/publish
|
||||||
|
POST /api/v1/sops/:id/offline
|
||||||
|
```
|
||||||
|
|
||||||
|
### SOP 执行接口
|
||||||
|
|
||||||
|
```text
|
||||||
|
POST /api/v1/runs
|
||||||
|
GET /api/v1/runs/:id
|
||||||
|
POST /api/v1/runs/:id/answer
|
||||||
|
POST /api/v1/runs/:id/finish
|
||||||
|
POST /api/v1/runs/:id/feedback
|
||||||
|
```
|
||||||
|
|
||||||
|
## 11. 流程执行引擎
|
||||||
|
|
||||||
|
执行流程如下:
|
||||||
|
|
||||||
|
1. 创建执行实例,绑定一个已发布的 SOP 版本。
|
||||||
|
2. 加载开始节点并返回给前端。
|
||||||
|
3. 前端展示话术、表单或选项。
|
||||||
|
4. 后端校验客户回答和字段类型。
|
||||||
|
5. 在事务中保存回答和节点事件。
|
||||||
|
6. 根据结构化条件计算下一节点。
|
||||||
|
7. 返回下一节点或结束动作。
|
||||||
|
|
||||||
|
流程引擎只处理通用节点和规则,不理解“宠物”“保险”等具体业务。行业知识放在场景字段、话术内容和知识卡中。
|
||||||
|
|
||||||
|
## 12. Zap 日志规范
|
||||||
|
|
||||||
|
使用 Zap 输出结构化 JSON 日志到 stdout,由 Docker、Kubernetes 或云平台采集,不由应用自己维护日志文件。
|
||||||
|
|
||||||
|
日志至少包含:
|
||||||
|
|
||||||
|
- `service`
|
||||||
|
- `env`
|
||||||
|
- `request_id`
|
||||||
|
- `tenant_id`
|
||||||
|
- `user_id`
|
||||||
|
- `action`
|
||||||
|
- `resource`
|
||||||
|
- `resource_id`
|
||||||
|
- `duration_ms`
|
||||||
|
- `err`
|
||||||
|
|
||||||
|
日志等级约定:
|
||||||
|
|
||||||
|
- `DEBUG`:本地开发细节
|
||||||
|
- `INFO`:正常请求、发布、执行等业务事件
|
||||||
|
- `WARN`:参数异常、权限拒绝、客户端错误
|
||||||
|
- `ERROR`:服务异常、数据库失败、流程执行失败
|
||||||
|
|
||||||
|
禁止记录密码、Token、完整客户聊天内容和未脱敏的个人信息。
|
||||||
|
|
||||||
|
Gin 请求日志中需要记录请求方法、路径、状态码、耗时和 Request ID。业务日志使用结构化字段,不使用字符串拼接。
|
||||||
|
|
||||||
|
## 13. 前端页面规划
|
||||||
|
|
||||||
|
### 管理端
|
||||||
|
|
||||||
|
- 场景列表
|
||||||
|
- 创建/编辑场景
|
||||||
|
- 自定义字段配置
|
||||||
|
- SOP 列表
|
||||||
|
- SOP 步骤编辑器
|
||||||
|
- 节点条件配置
|
||||||
|
- 知识卡管理
|
||||||
|
- 审核发布中心
|
||||||
|
- 执行数据看板
|
||||||
|
|
||||||
|
### 执行端
|
||||||
|
|
||||||
|
- 场景选择
|
||||||
|
- SOP 当前节点
|
||||||
|
- 客户信息录入
|
||||||
|
- 标准话术展示
|
||||||
|
- 分支选择
|
||||||
|
- 转人工或转诊提醒
|
||||||
|
- 执行结果和反馈
|
||||||
|
|
||||||
|
Ant Design Vue 主要使用 `Form`、`Table`、`Drawer`、`Modal`、`Steps`、`Tree`、`Tabs` 和 `Descriptions`。动态字段表单由场景字段配置生成,字段校验规则也由配置生成。
|
||||||
|
|
||||||
|
## 14. 安全与权限
|
||||||
|
|
||||||
|
- 用户登录后从 Token 中获取 `tenant_id`,不能信任前端传入的租户 ID。
|
||||||
|
- 所有查询必须自动附加租户条件。
|
||||||
|
- 场景、SOP、知识卡、执行记录都需要做资源级权限判断。
|
||||||
|
- 发布、下线、审核和修改权限分离。
|
||||||
|
- 已发布 SOP 版本只读,修改时复制为新的草稿版本。
|
||||||
|
- 记录关键操作到 `audit_logs`。
|
||||||
|
- 条件规则禁止执行任意代码。
|
||||||
|
|
||||||
|
## 15. 第一阶段开发顺序
|
||||||
|
|
||||||
|
### 第 1 阶段:基础服务
|
||||||
|
|
||||||
|
- Go 服务初始化
|
||||||
|
- Zap 日志
|
||||||
|
- 配置加载
|
||||||
|
- MySQL 连接
|
||||||
|
- 登录和租户权限
|
||||||
|
- 前端构建和 go:embed
|
||||||
|
|
||||||
|
### 第 2 阶段:场景配置
|
||||||
|
|
||||||
|
- 场景 CRUD
|
||||||
|
- 自定义字段 CRUD
|
||||||
|
- 场景权限
|
||||||
|
- 场景状态管理
|
||||||
|
|
||||||
|
### 第 3 阶段:SOP 编排
|
||||||
|
|
||||||
|
- 节点管理
|
||||||
|
- 连线和条件配置
|
||||||
|
- 草稿保存
|
||||||
|
- 流程校验
|
||||||
|
- 版本复制
|
||||||
|
|
||||||
|
### 第 4 阶段:SOP 执行
|
||||||
|
|
||||||
|
- 创建执行实例
|
||||||
|
- 节点展示
|
||||||
|
- 回答提交
|
||||||
|
- 条件跳转
|
||||||
|
- 执行事件记录
|
||||||
|
|
||||||
|
### 第 5 阶段:审核和反馈
|
||||||
|
|
||||||
|
- 提交审核
|
||||||
|
- 审核发布
|
||||||
|
- 下线和回滚
|
||||||
|
- 执行反馈
|
||||||
|
- 基础数据统计
|
||||||
|
|
||||||
|
## 16. 构建和部署约定
|
||||||
|
|
||||||
|
首次初始化本地 MySQL 数据库时执行:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd codes
|
||||||
|
mysql -uroot -p < scripts/create-databases.sql
|
||||||
|
```
|
||||||
|
|
||||||
|
服务启动时会自动执行 `migrations/` 中的迁移,创建业务表并记录 `schema_migrations` 版本。
|
||||||
|
|
||||||
|
本地开发时前后端可以分别启动:
|
||||||
|
|
||||||
|
```text
|
||||||
|
前端开发服务:codes/web
|
||||||
|
Go API 服务:codes
|
||||||
|
```
|
||||||
|
|
||||||
|
生产构建时先构建前端,再构建 Go 服务。最终产物只需要:
|
||||||
|
|
||||||
|
- Go 二进制
|
||||||
|
- 配置文件
|
||||||
|
- MySQL 数据库
|
||||||
|
|
||||||
|
前端静态资源已经通过 `go:embed` 编译进入 Go 二进制,不需要单独部署前端静态目录。
|
||||||
|
|
||||||
|
## 17. 暂不实现的内容
|
||||||
|
|
||||||
|
- 文件上传和对象存储
|
||||||
|
- AI 自动提炼话术
|
||||||
|
- AI 客户模拟陪练
|
||||||
|
- CRM、企业微信和客服系统集成
|
||||||
|
- 多服务拆分
|
||||||
|
- Redis 缓存
|
||||||
|
- 消息队列
|
||||||
|
- 复杂 BI 分析
|
||||||
|
|
||||||
|
这些内容等核心闭环验证成功后再加入,避免第一版架构过度复杂。
|
||||||
Reference in New Issue
Block a user