feat: implement scenario-driven sales SOP platform

This commit is contained in:
Eric 1549169735@qq.com
2026-08-06 21:37:29 +08:00
parent 254469ab89
commit de5345607a
84 changed files with 7132 additions and 2 deletions

11
.gitignore vendored
View File

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

View 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: 平台管理员

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

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

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

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

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

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

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

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

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

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

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

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

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

View 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"`
}

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

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

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

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

View 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", "查询失败")
}

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

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

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

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

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

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

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

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

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

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

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

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

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

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

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

File diff suppressed because one or more lines are too long

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

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

View File

@@ -0,0 +1 @@
code[data-v-4d911d56]{color:#17654f;background:#eef5f2;border-radius:3px;padding:2px 5px}

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

File diff suppressed because one or more lines are too long

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

File diff suppressed because one or more lines are too long

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

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

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

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

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

327
codes/web/dist/assets/index-DL8F6dio.js vendored Normal file

File diff suppressed because one or more lines are too long

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

File diff suppressed because it is too large Load Diff

27
codes/web/package.json Normal file
View 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
View File

@@ -0,0 +1,3 @@
<template>
<router-view />
</template>

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

@@ -0,0 +1 @@
/// <reference types="vite/client" />

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

View 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

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

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

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

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

View 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'&&currentFields.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>

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

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

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

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

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

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

View File

@@ -0,0 +1,493 @@
# 销冠平台技术实现方案
## 1. 文档范围
本文档用于指导“场景化销售话术与 SOP 平台”的第一阶段实现。
第一阶段的核心闭环是:
```text
创建场景 -> 配置自定义字段 -> 编排 SOP -> 审核发布 -> 一线人员执行 -> 记录反馈
```
场景不写死在代码中。宠物医生问诊问药只是一个配置示例,后续可以创建保险咨询、房地产销售、教育课程顾问等其他场景。
本阶段暂不实现文件存储、AI 自动生成、CRM 集成和微服务拆分。
## 2. 技术选型
### 2.1 后端
- Go业务服务和流程执行引擎
- GinHTTP 路由、中间件和请求处理
- Zap结构化日志
- cleanenvYAML 配置解析和环境变量覆盖
- GORMMySQL 数据访问
- 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 分析
这些内容等核心闭环验证成功后再加入,避免第一版架构过度复杂。