feat: complete SOP version management
This commit is contained in:
@@ -98,6 +98,14 @@ func Load(options LoadOptions) (Config, error) {
|
|||||||
if environment != "" {
|
if environment != "" {
|
||||||
cfg.App.Env = environment
|
cfg.App.Env = environment
|
||||||
}
|
}
|
||||||
|
if cfg.App.Env == "prod" {
|
||||||
|
if strings.TrimSpace(os.Getenv("APP_DATABASE_PASSWORD")) == "" {
|
||||||
|
return Config{}, errors.New("APP_DATABASE_PASSWORD must be set in production")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(os.Getenv("APP_AUTH_JWT_SECRET")) == "" {
|
||||||
|
return Config{}, errors.New("APP_AUTH_JWT_SECRET must be set in production")
|
||||||
|
}
|
||||||
|
}
|
||||||
if err := cfg.Validate(); err != nil {
|
if err := cfg.Validate(); err != nil {
|
||||||
return Config{}, err
|
return Config{}, err
|
||||||
}
|
}
|
||||||
|
|||||||
88
internal/config/config_test.go
Normal file
88
internal/config/config_test.go
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
const baseConfig = `
|
||||||
|
app:
|
||||||
|
name: test-app
|
||||||
|
env: development
|
||||||
|
server:
|
||||||
|
host: 127.0.0.1
|
||||||
|
port: 8080
|
||||||
|
database:
|
||||||
|
host: 127.0.0.1
|
||||||
|
port: 3306
|
||||||
|
name: base_db
|
||||||
|
user: base_user
|
||||||
|
password: base_password
|
||||||
|
auth:
|
||||||
|
jwt_secret: base-development-secret
|
||||||
|
seed:
|
||||||
|
admin_username: admin
|
||||||
|
admin_password: admin123
|
||||||
|
admin_display_name: Admin
|
||||||
|
`
|
||||||
|
|
||||||
|
func TestLoadProfileThenEnvironmentOverride(t *testing.T) {
|
||||||
|
dir := writeConfigs(t, `
|
||||||
|
app:
|
||||||
|
env: test
|
||||||
|
server:
|
||||||
|
port: 8081
|
||||||
|
database:
|
||||||
|
name: profile_db
|
||||||
|
auth:
|
||||||
|
jwt_secret: test-profile-secret
|
||||||
|
`)
|
||||||
|
t.Setenv("APP_DATABASE_NAME", "environment_db")
|
||||||
|
t.Setenv("APP_SERVER_PORT", "9090")
|
||||||
|
|
||||||
|
cfg, err := Load(LoadOptions{Environment: "test", ConfigDir: dir})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Load() error = %v", err)
|
||||||
|
}
|
||||||
|
if cfg.App.Env != "test" || cfg.Database.Name != "environment_db" || cfg.Database.User != "base_user" || cfg.Server.Port != 9090 {
|
||||||
|
t.Fatalf("unexpected merged config: %+v", cfg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadProductionRequiresEnvironmentSecrets(t *testing.T) {
|
||||||
|
dir := writeConfigs(t, "app:\n env: production\n")
|
||||||
|
t.Setenv("APP_DATABASE_PASSWORD", "")
|
||||||
|
t.Setenv("APP_AUTH_JWT_SECRET", "")
|
||||||
|
|
||||||
|
_, err := Load(LoadOptions{Environment: "prod", ConfigDir: dir})
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "APP_DATABASE_PASSWORD") {
|
||||||
|
t.Fatalf("Load() error = %v, want production password requirement", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Setenv("APP_DATABASE_PASSWORD", "production-password")
|
||||||
|
t.Setenv("APP_AUTH_JWT_SECRET", "production-jwt-secret")
|
||||||
|
cfg, err := Load(LoadOptions{Environment: "prod", ConfigDir: dir})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Load() with environment secrets error = %v", err)
|
||||||
|
}
|
||||||
|
if cfg.Database.Password != "production-password" || cfg.Auth.JWTSecret != "production-jwt-secret" {
|
||||||
|
t.Fatalf("environment secrets were not applied")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeConfigs(t *testing.T, profile string) string {
|
||||||
|
t.Helper()
|
||||||
|
dir := t.TempDir()
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, "config.yml"), []byte(baseConfig), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, "config.test.yml"), []byte(profile), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, "config.prod.yml"), []byte(profile), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return dir
|
||||||
|
}
|
||||||
@@ -63,6 +63,7 @@ func New(db *gorm.DB, authService *auth.Service, frontend fs.FS, log *zap.Logger
|
|||||||
protected.GET("/scenarios/:id/sops", middleware.RequirePermission("sop.view"), sopHandler.List)
|
protected.GET("/scenarios/:id/sops", middleware.RequirePermission("sop.view"), sopHandler.List)
|
||||||
protected.POST("/scenarios/:id/sops", middleware.RequirePermission("sop.edit"), sopHandler.Create)
|
protected.POST("/scenarios/:id/sops", middleware.RequirePermission("sop.edit"), sopHandler.Create)
|
||||||
protected.GET("/sops/:id", middleware.RequirePermission("sop.view"), sopHandler.Get)
|
protected.GET("/sops/:id", middleware.RequirePermission("sop.view"), sopHandler.Get)
|
||||||
|
protected.GET("/sops/:id/versions", middleware.RequirePermission("sop.view"), sopHandler.ListVersions)
|
||||||
protected.PUT("/sops/:id/draft", middleware.RequirePermission("sop.edit"), sopHandler.SaveGraph)
|
protected.PUT("/sops/:id/draft", middleware.RequirePermission("sop.edit"), sopHandler.SaveGraph)
|
||||||
protected.POST("/sops/:id/validate", middleware.RequireAnyPermission("sop.view", "sop.edit"), sopHandler.Validate)
|
protected.POST("/sops/:id/validate", middleware.RequireAnyPermission("sop.view", "sop.edit"), sopHandler.Validate)
|
||||||
protected.POST("/sops/:id/submit-review", middleware.RequirePermission("sop.submit_review"), sopHandler.SubmitReview)
|
protected.POST("/sops/:id/submit-review", middleware.RequirePermission("sop.submit_review"), sopHandler.SubmitReview)
|
||||||
@@ -70,6 +71,7 @@ func New(db *gorm.DB, authService *auth.Service, frontend fs.FS, log *zap.Logger
|
|||||||
protected.POST("/sops/:id/reject", middleware.RequirePermission("sop.review"), sopHandler.Reject)
|
protected.POST("/sops/:id/reject", middleware.RequirePermission("sop.review"), sopHandler.Reject)
|
||||||
protected.POST("/sops/:id/offline", middleware.RequirePermission("sop.publish"), sopHandler.Offline)
|
protected.POST("/sops/:id/offline", middleware.RequirePermission("sop.publish"), sopHandler.Offline)
|
||||||
protected.POST("/sops/:id/versions", middleware.RequirePermission("sop.edit"), sopHandler.CreateVersion)
|
protected.POST("/sops/:id/versions", middleware.RequirePermission("sop.edit"), sopHandler.CreateVersion)
|
||||||
|
protected.POST("/sops/:id/rollback", middleware.RequirePermission("sop.publish"), sopHandler.Rollback)
|
||||||
protected.GET("/reviews", middleware.RequirePermission("sop.review"), sopHandler.Reviews)
|
protected.GET("/reviews", middleware.RequirePermission("sop.review"), sopHandler.Reviews)
|
||||||
|
|
||||||
protected.GET("/published-sops", middleware.RequirePermission("sop.execute"), runHandler.PublishedSOPs)
|
protected.GET("/published-sops", middleware.RequirePermission("sop.execute"), runHandler.PublishedSOPs)
|
||||||
@@ -83,6 +85,8 @@ func New(db *gorm.DB, authService *auth.Service, frontend fs.FS, log *zap.Logger
|
|||||||
|
|
||||||
protected.GET("/knowledge-cards", middleware.RequirePermission("knowledge.view"), knowledgeHandler.List)
|
protected.GET("/knowledge-cards", middleware.RequirePermission("knowledge.view"), knowledgeHandler.List)
|
||||||
protected.POST("/knowledge-cards", middleware.RequirePermission("knowledge.edit"), knowledgeHandler.Create)
|
protected.POST("/knowledge-cards", middleware.RequirePermission("knowledge.edit"), knowledgeHandler.Create)
|
||||||
|
protected.GET("/knowledge-cards/:id/versions", middleware.RequirePermission("knowledge.view"), knowledgeHandler.Versions)
|
||||||
|
protected.PUT("/knowledge-cards/:id", middleware.RequirePermission("knowledge.edit"), knowledgeHandler.Update)
|
||||||
protected.DELETE("/knowledge-cards/:id", middleware.RequirePermission("knowledge.edit"), knowledgeHandler.Delete)
|
protected.DELETE("/knowledge-cards/:id", middleware.RequirePermission("knowledge.edit"), knowledgeHandler.Delete)
|
||||||
|
|
||||||
router.NoRoute(spaHandler(frontend))
|
router.NoRoute(spaHandler(frontend))
|
||||||
|
|||||||
@@ -2,8 +2,10 @@ package knowledge
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"git.iwork-ai.com/xdc/iqudo-top1/internal/access"
|
"git.iwork-ai.com/xdc/iqudo-top1/internal/access"
|
||||||
"git.iwork-ai.com/xdc/iqudo-top1/internal/audit"
|
"git.iwork-ai.com/xdc/iqudo-top1/internal/audit"
|
||||||
@@ -13,6 +15,7 @@ import (
|
|||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"gorm.io/datatypes"
|
"gorm.io/datatypes"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
|
"gorm.io/gorm/clause"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Handler struct{ db *gorm.DB }
|
type Handler struct{ db *gorm.DB }
|
||||||
@@ -25,16 +28,22 @@ type input struct {
|
|||||||
Content map[string]interface{} `json:"content" binding:"required"`
|
Content map[string]interface{} `json:"content" binding:"required"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type updateInput struct {
|
||||||
|
Title string `json:"title" binding:"required,max=128"`
|
||||||
|
Content map[string]interface{} `json:"content" binding:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
func (h *Handler) List(c *gin.Context) {
|
func (h *Handler) List(c *gin.Context) {
|
||||||
p, _ := auth.PrincipalFromContext(c)
|
p, _ := auth.PrincipalFromContext(c)
|
||||||
type row struct {
|
type row struct {
|
||||||
model.KnowledgeCard
|
model.KnowledgeCard
|
||||||
Content datatypes.JSON `json:"content"`
|
Content datatypes.JSON `json:"content"`
|
||||||
|
Version int `json:"version"`
|
||||||
}
|
}
|
||||||
items := make([]row, 0)
|
items := make([]row, 0)
|
||||||
query := h.db.Table("knowledge_cards kc").Select("kc.*, kcv.content").Joins("JOIN scenarios sc ON sc.id = kc.scenario_id").Joins("LEFT JOIN knowledge_card_versions kcv ON kcv.knowledge_card_id = kc.id AND kcv.status = ?", "published")
|
query := h.db.Table("knowledge_cards kc").Select("kc.*, kcv.content, kcv.version").Joins("JOIN scenarios sc ON sc.id = kc.scenario_id").Joins("LEFT JOIN knowledge_card_versions kcv ON kcv.knowledge_card_id = kc.id AND kcv.status = ?", "published")
|
||||||
query = access.ScopeScenarios(query, p, "sc")
|
query = access.ScopeScenarios(query, p, "sc")
|
||||||
err := query.Where("kc.tenant_id = ? AND kc.status <> ?", p.TenantID, "archived").Order("kc.updated_at DESC").Scan(&items).Error
|
err := query.Where("kc.tenant_id = ? AND kc.status <> ? AND sc.status <> ?", p.TenantID, "archived", "archived").Order("kc.updated_at DESC").Scan(&items).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
response.Error(c, http.StatusInternalServerError, "QUERY_FAILED", "查询知识卡失败")
|
response.Error(c, http.StatusInternalServerError, "QUERY_FAILED", "查询知识卡失败")
|
||||||
return
|
return
|
||||||
@@ -42,6 +51,78 @@ func (h *Handler) List(c *gin.Context) {
|
|||||||
response.OK(c, gin.H{"items": items, "total": len(items)})
|
response.OK(c, gin.H{"items": items, "total": len(items)})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *Handler) Update(c *gin.Context) {
|
||||||
|
p, _ := auth.PrincipalFromContext(c)
|
||||||
|
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||||
|
if err != nil || id == 0 {
|
||||||
|
response.Error(c, http.StatusBadRequest, "INVALID_ID", "知识卡 ID 不正确")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var body updateInput
|
||||||
|
if err := c.ShouldBindJSON(&body); err != nil {
|
||||||
|
response.Error(c, http.StatusBadRequest, "INVALID_ARGUMENT", "知识卡内容不完整")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := validateContent(body.Content); err != nil {
|
||||||
|
response.Error(c, http.StatusBadRequest, "INVALID_ARGUMENT", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
content, err := json.Marshal(body.Content)
|
||||||
|
if err != nil {
|
||||||
|
response.Error(c, http.StatusBadRequest, "INVALID_ARGUMENT", "知识卡内容格式不正确")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var card model.KnowledgeCard
|
||||||
|
if err := h.db.Where("id = ? AND tenant_id = ? AND status <> ?", id, p.TenantID, "archived").First(&card).Error; err != nil || !access.CanEditScenario(h.db, p, card.ScenarioID) {
|
||||||
|
response.Error(c, http.StatusNotFound, "NOT_FOUND", "知识卡不存在或不可编辑")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var version model.KnowledgeCardVersion
|
||||||
|
err = h.db.Transaction(func(tx *gorm.DB) error {
|
||||||
|
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("id = ? AND tenant_id = ?", id, p.TenantID).First(&card).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var maxVersion int
|
||||||
|
if err := tx.Model(&model.KnowledgeCardVersion{}).Where("knowledge_card_id = ? AND tenant_id = ?", id, p.TenantID).Select("COALESCE(MAX(version), 0)").Scan(&maxVersion).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := tx.Model(&model.KnowledgeCardVersion{}).Where("knowledge_card_id = ? AND tenant_id = ? AND status = ?", id, p.TenantID, "published").Update("status", "superseded").Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
version = model.KnowledgeCardVersion{TenantID: p.TenantID, KnowledgeCardID: id, Version: maxVersion + 1, Content: datatypes.JSON(content), Status: "published"}
|
||||||
|
if err := tx.Create(&version).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return tx.Model(&card).Updates(map[string]interface{}{"title": body.Title, "status": "published"}).Error
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
response.Error(c, http.StatusInternalServerError, "UPDATE_FAILED", "更新知识卡失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = audit.Record(h.db, p, "update", "knowledge_card", id, gin.H{"version": version.Version})
|
||||||
|
response.OK(c, gin.H{"id": id, "title": body.Title, "version": version.Version, "content": datatypes.JSON(content)})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) Versions(c *gin.Context) {
|
||||||
|
p, _ := auth.PrincipalFromContext(c)
|
||||||
|
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||||
|
if err != nil || id == 0 {
|
||||||
|
response.Error(c, http.StatusBadRequest, "INVALID_ID", "知识卡 ID 不正确")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var card model.KnowledgeCard
|
||||||
|
if err := h.db.Where("id = ? AND tenant_id = ? AND status <> ?", id, p.TenantID, "archived").First(&card).Error; err != nil || !access.CanViewScenario(h.db, p, card.ScenarioID) {
|
||||||
|
response.Error(c, http.StatusNotFound, "NOT_FOUND", "知识卡不存在")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
items := make([]model.KnowledgeCardVersion, 0)
|
||||||
|
if err := h.db.Where("knowledge_card_id = ? AND tenant_id = ?", id, p.TenantID).Order("version DESC").Find(&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) Create(c *gin.Context) {
|
func (h *Handler) Create(c *gin.Context) {
|
||||||
p, _ := auth.PrincipalFromContext(c)
|
p, _ := auth.PrincipalFromContext(c)
|
||||||
var body input
|
var body input
|
||||||
@@ -49,6 +130,10 @@ func (h *Handler) Create(c *gin.Context) {
|
|||||||
response.Error(c, http.StatusBadRequest, "INVALID_ARGUMENT", "知识卡内容不完整")
|
response.Error(c, http.StatusBadRequest, "INVALID_ARGUMENT", "知识卡内容不完整")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if err := validateContent(body.Content); err != nil {
|
||||||
|
response.Error(c, http.StatusBadRequest, "INVALID_ARGUMENT", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
if !access.CanEditScenario(h.db, p, body.ScenarioID) {
|
if !access.CanEditScenario(h.db, p, body.ScenarioID) {
|
||||||
response.Error(c, http.StatusNotFound, "NOT_FOUND", "场景不存在或不可编辑")
|
response.Error(c, http.StatusNotFound, "NOT_FOUND", "场景不存在或不可编辑")
|
||||||
return
|
return
|
||||||
@@ -103,3 +188,18 @@ func (h *Handler) Delete(c *gin.Context) {
|
|||||||
_ = audit.Record(h.db, p, "archive", "knowledge_card", id, nil)
|
_ = audit.Record(h.db, p, "archive", "knowledge_card", id, nil)
|
||||||
response.OK(c, gin.H{"id": id})
|
response.OK(c, gin.H{"id": id})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func validateContent(content map[string]interface{}) error {
|
||||||
|
standard, ok := content["standard_copy"].(string)
|
||||||
|
if !ok || strings.TrimSpace(standard) == "" {
|
||||||
|
return errors.New("请填写标准话术")
|
||||||
|
}
|
||||||
|
for _, key := range []string{"forbidden_copy", "risk_note"} {
|
||||||
|
if value, exists := content[key]; exists {
|
||||||
|
if _, ok := value.(string); !ok {
|
||||||
|
return errors.New("知识卡文本字段格式不正确")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|||||||
23
internal/knowledge/handler_test.go
Normal file
23
internal/knowledge/handler_test.go
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
package knowledge
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestValidateContent(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
content map[string]interface{}
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{name: "valid", content: map[string]interface{}{"standard_copy": "标准表达", "risk_note": "风险提示"}},
|
||||||
|
{name: "missing standard copy", content: map[string]interface{}{"risk_note": "风险提示"}, wantErr: true},
|
||||||
|
{name: "blank standard copy", content: map[string]interface{}{"standard_copy": " "}, wantErr: true},
|
||||||
|
{name: "invalid risk note", content: map[string]interface{}{"standard_copy": "标准表达", "risk_note": 1}, wantErr: true},
|
||||||
|
}
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
if err := validateContent(test.content); (err != nil) != test.wantErr {
|
||||||
|
t.Fatalf("validateContent() error = %v, wantErr %v", err, test.wantErr)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -24,9 +24,10 @@ func RequestLogger(log *zap.Logger) gin.HandlerFunc {
|
|||||||
c.Header("X-Request-ID", requestID)
|
c.Header("X-Request-ID", requestID)
|
||||||
c.Next()
|
c.Next()
|
||||||
|
|
||||||
|
duration := time.Since(started)
|
||||||
fields := []zap.Field{
|
fields := []zap.Field{
|
||||||
zap.String("request_id", requestID), zap.String("method", c.Request.Method), zap.String("path", c.Request.URL.Path),
|
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()),
|
zap.Int("status", c.Writer.Status()), zap.Int("response_bytes", c.Writer.Size()), zap.Int64("duration_ms", duration.Milliseconds()), zap.String("client_ip", c.ClientIP()),
|
||||||
}
|
}
|
||||||
if principal, ok := auth.PrincipalFromContext(c); ok {
|
if principal, ok := auth.PrincipalFromContext(c); ok {
|
||||||
fields = append(fields, zap.Uint64("tenant_id", principal.TenantID), zap.Uint64("user_id", principal.UserID))
|
fields = append(fields, zap.Uint64("tenant_id", principal.TenantID), zap.Uint64("user_id", principal.UserID))
|
||||||
@@ -34,9 +35,16 @@ func RequestLogger(log *zap.Logger) gin.HandlerFunc {
|
|||||||
if len(c.Errors) > 0 {
|
if len(c.Errors) > 0 {
|
||||||
fields = append(fields, zap.String("errors", c.Errors.String()))
|
fields = append(fields, zap.String("errors", c.Errors.String()))
|
||||||
}
|
}
|
||||||
|
switch status := c.Writer.Status(); {
|
||||||
|
case status >= http.StatusInternalServerError:
|
||||||
|
log.Error("http request", fields...)
|
||||||
|
case status >= http.StatusBadRequest:
|
||||||
|
log.Warn("http request", fields...)
|
||||||
|
default:
|
||||||
log.Info("http request", fields...)
|
log.Info("http request", fields...)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func Recovery(log *zap.Logger) gin.HandlerFunc {
|
func Recovery(log *zap.Logger) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
|
|||||||
32
internal/middleware/http_test.go
Normal file
32
internal/middleware/http_test.go
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
"go.uber.org/zap/zapcore"
|
||||||
|
"go.uber.org/zap/zaptest/observer"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRequestLoggerUsesStatusLevelAndDurationMilliseconds(t *testing.T) {
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
core, logs := observer.New(zapcore.DebugLevel)
|
||||||
|
router := gin.New()
|
||||||
|
router.Use(RequestLogger(zap.New(core)))
|
||||||
|
router.GET("/bad", func(c *gin.Context) { c.Status(http.StatusBadRequest) })
|
||||||
|
|
||||||
|
request := httptest.NewRequest(http.MethodGet, "/bad", nil)
|
||||||
|
response := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(response, request)
|
||||||
|
|
||||||
|
entries := logs.All()
|
||||||
|
if len(entries) != 1 || entries[0].Level != zapcore.WarnLevel {
|
||||||
|
t.Fatalf("log entries = %+v, want one warning", entries)
|
||||||
|
}
|
||||||
|
if _, exists := entries[0].ContextMap()["duration_ms"]; !exists {
|
||||||
|
t.Fatalf("duration_ms is missing from request log")
|
||||||
|
}
|
||||||
|
}
|
||||||
30
internal/scenario/field_validation_test.go
Normal file
30
internal/scenario/field_validation_test.go
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
package scenario
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestValidateFieldInput(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
input fieldInput
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{name: "text without options", input: fieldInput{FieldKey: "customer_name", FieldType: "text"}},
|
||||||
|
{name: "select options", input: fieldInput{FieldKey: "pet_type", FieldType: "select", Options: json.RawMessage(`["猫","狗"]`)}},
|
||||||
|
{name: "select missing options", input: fieldInput{FieldKey: "pet_type", FieldType: "select"}, wantErr: true},
|
||||||
|
{name: "invalid key", input: fieldInput{FieldKey: "1pet", FieldType: "text"}, wantErr: true},
|
||||||
|
{name: "options object", input: fieldInput{FieldKey: "pet_type", FieldType: "select", Options: json.RawMessage(`{"猫":1}`)}, wantErr: true},
|
||||||
|
{name: "options null", input: fieldInput{FieldKey: "pet_note", FieldType: "text", Options: json.RawMessage(`null`)}, wantErr: true},
|
||||||
|
{name: "validation array", input: fieldInput{FieldKey: "pet_age", FieldType: "number", Validation: json.RawMessage(`[1]`)}, wantErr: true},
|
||||||
|
{name: "validation null", input: fieldInput{FieldKey: "pet_age", FieldType: "number", Validation: json.RawMessage(`null`)}, wantErr: true},
|
||||||
|
}
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
if err := validateFieldInput(test.input); (err != nil) != test.wantErr {
|
||||||
|
t.Fatalf("validateFieldInput() error = %v, wantErr %v", err, test.wantErr)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"git.iwork-ai.com/xdc/iqudo-top1/internal/access"
|
"git.iwork-ai.com/xdc/iqudo-top1/internal/access"
|
||||||
"git.iwork-ai.com/xdc/iqudo-top1/internal/audit"
|
"git.iwork-ai.com/xdc/iqudo-top1/internal/audit"
|
||||||
@@ -188,8 +189,8 @@ func (h *Handler) CreateField(c *gin.Context) {
|
|||||||
response.Error(c, http.StatusBadRequest, "INVALID_ARGUMENT", "字段配置不正确")
|
response.Error(c, http.StatusBadRequest, "INVALID_ARGUMENT", "字段配置不正确")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if !fieldKeyPattern.MatchString(input.FieldKey) {
|
if err := validateFieldInput(input); err != nil {
|
||||||
response.Error(c, http.StatusBadRequest, "INVALID_ARGUMENT", "字段标识必须以字母开头,且只能包含字母、数字和下划线")
|
response.Error(c, http.StatusBadRequest, "INVALID_ARGUMENT", err.Error())
|
||||||
return
|
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}
|
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}
|
||||||
@@ -221,8 +222,8 @@ func (h *Handler) UpdateField(c *gin.Context) {
|
|||||||
response.Error(c, http.StatusBadRequest, "INVALID_ARGUMENT", "字段配置不正确")
|
response.Error(c, http.StatusBadRequest, "INVALID_ARGUMENT", "字段配置不正确")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if !fieldKeyPattern.MatchString(input.FieldKey) {
|
if err := validateFieldInput(input); err != nil {
|
||||||
response.Error(c, http.StatusBadRequest, "INVALID_ARGUMENT", "字段标识必须以字母开头,且只能包含字母、数字和下划线")
|
response.Error(c, http.StatusBadRequest, "INVALID_ARGUMENT", err.Error())
|
||||||
return
|
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}
|
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}
|
||||||
@@ -268,6 +269,35 @@ func normalizedJSON(value json.RawMessage, fallback string) datatypes.JSON {
|
|||||||
return datatypes.JSON(value)
|
return datatypes.JSON(value)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func validateFieldInput(input fieldInput) error {
|
||||||
|
if !fieldKeyPattern.MatchString(input.FieldKey) {
|
||||||
|
return errors.New("字段标识必须以字母开头,且只能包含字母、数字和下划线")
|
||||||
|
}
|
||||||
|
if len(input.Options) > 0 {
|
||||||
|
var options []string
|
||||||
|
if err := json.Unmarshal(input.Options, &options); err != nil || options == nil {
|
||||||
|
return errors.New("字段选项必须是文本数组")
|
||||||
|
}
|
||||||
|
for _, option := range options {
|
||||||
|
if strings.TrimSpace(option) == "" {
|
||||||
|
return errors.New("字段选项不能为空")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (input.FieldType == "select" || input.FieldType == "multiselect") && len(options) == 0 {
|
||||||
|
return errors.New("单选或多选字段至少需要一个选项")
|
||||||
|
}
|
||||||
|
} else if input.FieldType == "select" || input.FieldType == "multiselect" {
|
||||||
|
return errors.New("单选或多选字段至少需要一个选项")
|
||||||
|
}
|
||||||
|
if len(input.Validation) > 0 {
|
||||||
|
var validation map[string]interface{}
|
||||||
|
if err := json.Unmarshal(input.Validation, &validation); err != nil || validation == nil {
|
||||||
|
return errors.New("字段校验规则必须是 JSON 对象")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func idParam(c *gin.Context, key string) (uint64, bool) {
|
func idParam(c *gin.Context, key string) (uint64, bool) {
|
||||||
id, err := strconv.ParseUint(c.Param(key), 10, 64)
|
id, err := strconv.ParseUint(c.Param(key), 10, 64)
|
||||||
if err != nil || id == 0 {
|
if err != nil || id == 0 {
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import (
|
|||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"gorm.io/datatypes"
|
"gorm.io/datatypes"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
|
"gorm.io/gorm/clause"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Handler struct {
|
type Handler struct {
|
||||||
@@ -135,7 +136,21 @@ func (h *Handler) Get(c *gin.Context) {
|
|||||||
response.Error(c, http.StatusNotFound, "NOT_FOUND", "SOP 不存在")
|
response.Error(c, http.StatusNotFound, "NOT_FOUND", "SOP 不存在")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
item, version, nodes, edges, err := h.loadLatest(id, p.TenantID)
|
var item model.SOP
|
||||||
|
var version model.SOPVersion
|
||||||
|
var nodes []model.SOPNode
|
||||||
|
var edges []model.SOPEdge
|
||||||
|
var err error
|
||||||
|
if requested := c.Query("version"); requested != "" {
|
||||||
|
versionNumber, parseErr := strconv.Atoi(requested)
|
||||||
|
if parseErr != nil || versionNumber < 1 {
|
||||||
|
response.Error(c, http.StatusBadRequest, "INVALID_VERSION", "SOP 版本不正确")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
item, version, nodes, edges, err = h.loadVersion(id, p.TenantID, versionNumber)
|
||||||
|
} else {
|
||||||
|
item, version, nodes, edges, err = h.loadLatest(id, p.TenantID)
|
||||||
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
response.Error(c, http.StatusNotFound, "NOT_FOUND", "SOP 不存在")
|
response.Error(c, http.StatusNotFound, "NOT_FOUND", "SOP 不存在")
|
||||||
@@ -147,6 +162,35 @@ func (h *Handler) Get(c *gin.Context) {
|
|||||||
response.OK(c, gin.H{"sop": item, "version": version, "nodes": nodes, "edges": edges})
|
response.OK(c, gin.H{"sop": item, "version": version, "nodes": nodes, "edges": edges})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type versionItem struct {
|
||||||
|
model.SOPVersion
|
||||||
|
CreatorName string `json:"creator_name"`
|
||||||
|
ReviewerName string `json:"reviewer_name"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) ListVersions(c *gin.Context) {
|
||||||
|
p, _ := auth.PrincipalFromContext(c)
|
||||||
|
id, ok := parseID(c, "id")
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !access.CanViewSOP(h.db, p, id) {
|
||||||
|
response.Error(c, http.StatusNotFound, "NOT_FOUND", "SOP 不存在")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
items := make([]versionItem, 0)
|
||||||
|
err := h.db.Table("sop_versions sv").Select("sv.*, creator.display_name AS creator_name, COALESCE(reviewer.display_name, '') AS reviewer_name").
|
||||||
|
Joins("JOIN users creator ON creator.id = sv.created_by").
|
||||||
|
Joins("LEFT JOIN users reviewer ON reviewer.id = sv.reviewed_by").
|
||||||
|
Where("sv.sop_id = ? AND sv.tenant_id = ?", id, p.TenantID).
|
||||||
|
Order("sv.version 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) SaveGraph(c *gin.Context) {
|
func (h *Handler) SaveGraph(c *gin.Context) {
|
||||||
p, _ := auth.PrincipalFromContext(c)
|
p, _ := auth.PrincipalFromContext(c)
|
||||||
id, ok := parseID(c, "id")
|
id, ok := parseID(c, "id")
|
||||||
@@ -208,7 +252,21 @@ func (h *Handler) Validate(c *gin.Context) {
|
|||||||
response.Error(c, http.StatusNotFound, "NOT_FOUND", "SOP 不存在")
|
response.Error(c, http.StatusNotFound, "NOT_FOUND", "SOP 不存在")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
item, version, nodes, edges, err := h.loadLatest(id, p.TenantID)
|
var item model.SOP
|
||||||
|
var version model.SOPVersion
|
||||||
|
var nodes []model.SOPNode
|
||||||
|
var edges []model.SOPEdge
|
||||||
|
var err error
|
||||||
|
if requested := c.Query("version"); requested != "" {
|
||||||
|
versionNumber, parseErr := strconv.Atoi(requested)
|
||||||
|
if parseErr != nil || versionNumber < 1 {
|
||||||
|
response.Error(c, http.StatusBadRequest, "INVALID_VERSION", "SOP 版本不正确")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
item, version, nodes, edges, err = h.loadVersion(id, p.TenantID, versionNumber)
|
||||||
|
} else {
|
||||||
|
item, version, nodes, edges, err = h.loadLatest(id, p.TenantID)
|
||||||
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
response.Error(c, http.StatusNotFound, "NOT_FOUND", "SOP 不存在")
|
response.Error(c, http.StatusNotFound, "NOT_FOUND", "SOP 不存在")
|
||||||
return
|
return
|
||||||
@@ -406,6 +464,85 @@ func (h *Handler) CreateVersion(c *gin.Context) {
|
|||||||
response.Created(c, version)
|
response.Created(c, version)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *Handler) Rollback(c *gin.Context) {
|
||||||
|
p, _ := auth.PrincipalFromContext(c)
|
||||||
|
id, ok := parseID(c, "id")
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !access.CanViewSOP(h.db, p, id) {
|
||||||
|
response.Error(c, http.StatusNotFound, "NOT_FOUND", "SOP 不存在")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var input struct {
|
||||||
|
Version int `json:"version" binding:"required,min=1"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&input); err != nil {
|
||||||
|
response.Error(c, http.StatusBadRequest, "INVALID_ARGUMENT", "请选择要回滚的历史版本")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
item, source, nodes, edges, err := h.loadVersion(id, p.TenantID, input.Version)
|
||||||
|
if err != nil || (source.Status != "superseded" && source.Status != "offline") {
|
||||||
|
response.Error(c, http.StatusConflict, "INVALID_ROLLBACK_VERSION", "只能回滚到已替换或已下线的历史版本")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
problems, validationErr := h.validateForPublish(item, source.StartNodeKey, nodes, edges, p.TenantID)
|
||||||
|
if validationErr != nil {
|
||||||
|
response.Error(c, http.StatusInternalServerError, "VALIDATION_FAILED", "校验历史版本失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(problems) > 0 {
|
||||||
|
response.Error(c, http.StatusUnprocessableEntity, "INVALID_GRAPH", problems[0])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
var restored model.SOPVersion
|
||||||
|
pendingVersionErr := errors.New("存在草稿或审核中的版本,请先处理后再回滚")
|
||||||
|
err = h.db.Transaction(func(tx *gorm.DB) error {
|
||||||
|
var locked model.SOP
|
||||||
|
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("id = ? AND tenant_id = ?", id, p.TenantID).First(&locked).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var pending int64
|
||||||
|
if err := tx.Model(&model.SOPVersion{}).Where("sop_id = ? AND tenant_id = ? AND status IN ?", id, p.TenantID, []string{"draft", "reviewing"}).Count(&pending).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if pending > 0 {
|
||||||
|
return pendingVersionErr
|
||||||
|
}
|
||||||
|
var maxVersion int
|
||||||
|
if err := tx.Model(&model.SOPVersion{}).Where("sop_id = ? AND tenant_id = ?", id, p.TenantID).Select("COALESCE(MAX(version), 0)").Scan(&maxVersion).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", "superseded").Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
reviewerID := p.UserID
|
||||||
|
restored = model.SOPVersion{TenantID: p.TenantID, SOPID: id, Version: maxVersion + 1, Status: "published", StartNodeKey: source.StartNodeKey, PublishedAt: &now, CreatedBy: p.UserID, ReviewedBy: &reviewerID}
|
||||||
|
if err := tx.Create(&restored).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := cloneGraph(tx, nodes, edges, restored.ID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := tx.Model(&locked).Update("status", "published").Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return tx.Model(&model.Scenario{}).Where("id = ? AND tenant_id = ?", locked.ScenarioID, p.TenantID).Update("status", "active").Error
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, pendingVersionErr) {
|
||||||
|
response.Error(c, http.StatusConflict, "PENDING_VERSION_EXISTS", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.Error(c, http.StatusInternalServerError, "ROLLBACK_FAILED", "回滚 SOP 失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = audit.Record(h.db, p, "rollback", "sop", id, gin.H{"source_version": source.Version, "new_version": restored.Version})
|
||||||
|
response.Created(c, gin.H{"id": id, "source_version": source.Version, "version": restored.Version, "published_at": now})
|
||||||
|
}
|
||||||
|
|
||||||
func (h *Handler) loadLatest(sopID, tenantID uint64) (model.SOP, model.SOPVersion, []model.SOPNode, []model.SOPEdge, error) {
|
func (h *Handler) loadLatest(sopID, tenantID uint64) (model.SOP, model.SOPVersion, []model.SOPNode, []model.SOPEdge, error) {
|
||||||
var item model.SOP
|
var item model.SOP
|
||||||
if err := h.db.Where("id = ? AND tenant_id = ?", sopID, tenantID).First(&item).Error; err != nil {
|
if err := h.db.Where("id = ? AND tenant_id = ?", sopID, tenantID).First(&item).Error; err != nil {
|
||||||
@@ -426,6 +563,46 @@ func (h *Handler) loadLatest(sopID, tenantID uint64) (model.SOP, model.SOPVersio
|
|||||||
return item, version, nodes, edges, nil
|
return item, version, nodes, edges, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *Handler) loadVersion(sopID, tenantID uint64, versionNumber int) (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 = ? AND version = ?", sopID, tenantID, versionNumber).First(&version).Error; err != nil {
|
||||||
|
return item, version, nil, nil, err
|
||||||
|
}
|
||||||
|
nodes := make([]model.SOPNode, 0)
|
||||||
|
edges := make([]model.SOPEdge, 0)
|
||||||
|
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 cloneGraph(tx *gorm.DB, nodes []model.SOPNode, edges []model.SOPEdge, versionID uint64) error {
|
||||||
|
for i := range nodes {
|
||||||
|
nodes[i].Base = model.Base{}
|
||||||
|
nodes[i].SOPVersionID = versionID
|
||||||
|
}
|
||||||
|
for i := range edges {
|
||||||
|
edges[i].Base = model.Base{}
|
||||||
|
edges[i].SOPVersionID = versionID
|
||||||
|
}
|
||||||
|
if len(nodes) > 0 {
|
||||||
|
if err := tx.Create(&nodes).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(edges) > 0 {
|
||||||
|
return tx.Create(&edges).Error
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (h *Handler) validateForPublish(item model.SOP, startNodeKey string, nodes []model.SOPNode, edges []model.SOPEdge, tenantID uint64) ([]string, error) {
|
func (h *Handler) validateForPublish(item model.SOP, startNodeKey string, nodes []model.SOPNode, edges []model.SOPEdge, tenantID uint64) ([]string, error) {
|
||||||
fields := make([]model.ScenarioField, 0)
|
fields := make([]model.ScenarioField, 0)
|
||||||
if err := h.db.Where("scenario_id = ? AND tenant_id = ?", item.ScenarioID, tenantID).Find(&fields).Error; err != nil {
|
if err := h.db.Where("scenario_id = ? AND tenant_id = ?", item.ScenarioID, tenantID).Find(&fields).Error; err != nil {
|
||||||
|
|||||||
@@ -179,12 +179,14 @@ func ValidateForPublish(startNodeKey string, nodes []model.SOPNode, edges []mode
|
|||||||
}
|
}
|
||||||
|
|
||||||
defaultPaths := map[string]int{}
|
defaultPaths := map[string]int{}
|
||||||
|
conditionalPaths := map[string]int{}
|
||||||
for _, edge := range edges {
|
for _, edge := range edges {
|
||||||
adjacency[edge.SourceNodeKey] = append(adjacency[edge.SourceNodeKey], edge.TargetNodeKey)
|
adjacency[edge.SourceNodeKey] = append(adjacency[edge.SourceNodeKey], edge.TargetNodeKey)
|
||||||
if isDefaultCondition(edge.Condition) {
|
if isDefaultCondition(edge.Condition) {
|
||||||
defaultPaths[edge.SourceNodeKey]++
|
defaultPaths[edge.SourceNodeKey]++
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
conditionalPaths[edge.SourceNodeKey]++
|
||||||
var rule interface{}
|
var rule interface{}
|
||||||
if err := json.Unmarshal(edge.Condition, &rule); err != nil {
|
if err := json.Unmarshal(edge.Condition, &rule); err != nil {
|
||||||
continue
|
continue
|
||||||
@@ -196,6 +198,11 @@ func ValidateForPublish(startNodeKey string, nodes []model.SOPNode, edges []mode
|
|||||||
problems = append(problems, fmt.Sprintf("节点 %s 配置了多条默认路径", source))
|
problems = append(problems, fmt.Sprintf("节点 %s 配置了多条默认路径", source))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
for source := range conditionalPaths {
|
||||||
|
if defaultPaths[source] == 0 {
|
||||||
|
problems = append(problems, fmt.Sprintf("节点 %s 包含条件路径但没有默认路径", source))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
for _, node := range nodes {
|
for _, node := range nodes {
|
||||||
var config map[string]interface{}
|
var config map[string]interface{}
|
||||||
|
|||||||
@@ -36,6 +36,9 @@ func TestValidateForPublishBusinessRules(t *testing.T) {
|
|||||||
{name: "duplicate default path", mutate: func(_ *[]model.SOPNode, edges *[]model.SOPEdge, _ *ValidationContext) {
|
{name: "duplicate default path", mutate: func(_ *[]model.SOPNode, edges *[]model.SOPEdge, _ *ValidationContext) {
|
||||||
(*edges)[1].Condition = jsonData(`{}`)
|
(*edges)[1].Condition = jsonData(`{}`)
|
||||||
}, want: "配置了多条默认路径"},
|
}, want: "配置了多条默认路径"},
|
||||||
|
{name: "conditional branch without default", mutate: func(_ *[]model.SOPNode, edges *[]model.SOPEdge, _ *ValidationContext) {
|
||||||
|
(*edges)[2].Condition = jsonData(`{"field":"emergency","operator":"not_equals","value":true}`)
|
||||||
|
}, want: "包含条件路径但没有默认路径"},
|
||||||
{name: "high risk without escalation", mutate: func(_ *[]model.SOPNode, edges *[]model.SOPEdge, _ *ValidationContext) {
|
{name: "high risk without escalation", mutate: func(_ *[]model.SOPNode, edges *[]model.SOPEdge, _ *ValidationContext) {
|
||||||
(*edges)[1].TargetNodeKey = "finish"
|
(*edges)[1].TargetNodeKey = "finish"
|
||||||
}, want: "没有明确的转人工或转诊路径"},
|
}, want: "没有明确的转人工或转诊路径"},
|
||||||
|
|||||||
1
web/dist/assets/EditOutlined-BIUW-zYI.js
vendored
Normal file
1
web/dist/assets/EditOutlined-BIUW-zYI.js
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
import{F as e,a as t}from"./client-Bk7n1O1W.js";var n={icon:{tag:`svg`,attrs:{viewBox:`64 64 896 896`,focusable:`false`},children:[{tag:`path`,attrs:{d:`M257.7 752c2 0 4-.2 6-.5L431.9 722c2-.4 3.9-1.3 5.3-2.8l423.9-423.9a9.96 9.96 0 000-14.1L694.9 114.9c-1.9-1.9-4.4-2.9-7.1-2.9s-5.2 1-7.1 2.9L256.8 538.8c-1.5 1.5-2.4 3.3-2.8 5.3l-29.5 168.2a33.5 33.5 0 009.4 29.8c6.6 6.4 14.9 9.9 23.8 9.9zm67.4-174.4L687.8 215l73.3 73.3-362.7 362.6-88.9 15.7 15.6-89zM880 836H144c-17.7 0-32 14.3-32 32v36c0 4.4 3.6 8 8 8h784c4.4 0 8-3.6 8-8v-36c0-17.7-14.3-32-32-32z`}}]},name:`edit`,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=`EditOutlined`,a.inheritAttrs=!1;export{a as t};
|
||||||
1
web/dist/assets/HistoryOutlined-ABEqcIfz.js
vendored
Normal file
1
web/dist/assets/HistoryOutlined-ABEqcIfz.js
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
import{F as e,a as t}from"./client-Bk7n1O1W.js";var n={icon:{tag:`svg`,attrs:{viewBox:`64 64 896 896`,focusable:`false`},children:[{tag:`path`,attrs:{d:`M536.1 273H488c-4.4 0-8 3.6-8 8v275.3c0 2.6 1.2 5 3.3 6.5l165.3 120.7c3.6 2.6 8.6 1.9 11.2-1.7l28.6-39c2.7-3.7 1.9-8.7-1.7-11.2L544.1 528.5V281c0-4.4-3.6-8-8-8zm219.8 75.2l156.8 38.3c5 1.2 9.9-2.6 9.9-7.7l.8-161.5c0-6.7-7.7-10.5-12.9-6.3L752.9 334.1a8 8 0 003 14.1zm167.7 301.1l-56.7-19.5a8 8 0 00-10.1 4.8c-1.9 5.1-3.9 10.1-6 15.1-17.8 42.1-43.3 80-75.9 112.5a353 353 0 01-112.5 75.9 352.18 352.18 0 01-137.7 27.8c-47.8 0-94.1-9.3-137.7-27.8a353 353 0 01-112.5-75.9c-32.5-32.5-58-70.4-75.9-112.5A353.44 353.44 0 01171 512c0-47.8 9.3-94.2 27.8-137.8 17.8-42.1 43.3-80 75.9-112.5a353 353 0 01112.5-75.9C430.6 167.3 477 158 524.8 158s94.1 9.3 137.7 27.8A353 353 0 01775 261.7c10.2 10.3 19.8 21 28.6 32.3l59.8-46.8C784.7 146.6 662.2 81.9 524.6 82 285 82.1 92.6 276.7 95 516.4 97.4 751.9 288.9 942 524.8 942c185.5 0 343.5-117.6 403.7-282.3 1.5-4.2-.7-8.9-4.9-10.4z`}}]},name:`history`,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=`HistoryOutlined`,a.inheritAttrs=!1;export{a as t};
|
||||||
1
web/dist/assets/KnowledgeView-B3gBHZL8.css
vendored
1
web/dist/assets/KnowledgeView-B3gBHZL8.css
vendored
@@ -1 +0,0 @@
|
|||||||
.knowledge-grid[data-v-68fdbe9b]{grid-template-columns:repeat(3,minmax(0,1fr));gap:14px;display:grid}.knowledge-card[data-v-68fdbe9b]{flex-direction:column;min-height:250px;padding:20px;display:flex}.knowledge-card header[data-v-68fdbe9b]{justify-content:space-between;display:flex}.knowledge-card header>span[data-v-68fdbe9b]{width:36px;height:36px;color:var(--green);background:#e8f3ef;border-radius:4px;place-items:center;display:grid}.knowledge-card h2[data-v-68fdbe9b]{margin:20px 0 10px;font-family:Noto Serif SC,serif;font-size:19px}.knowledge-card>p[data-v-68fdbe9b]{color:#4f5c56;margin:0;line-height:1.7}.risk-note[data-v-68fdbe9b]{color:#8b5b16;background:#fbf3e5;border-left:2px solid #c68a37;margin-top:14px;padding:9px 11px;font-size:12px}.knowledge-card footer[data-v-68fdbe9b]{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-68fdbe9b]{grid-template-columns:repeat(2,1fr)}}@media (width<=650px){.knowledge-grid[data-v-68fdbe9b]{grid-template-columns:1fr}}
|
|
||||||
1
web/dist/assets/KnowledgeView-D2bZOSSf.js
vendored
Normal file
1
web/dist/assets/KnowledgeView-D2bZOSSf.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
web/dist/assets/KnowledgeView-D6Il3x-6.js
vendored
1
web/dist/assets/KnowledgeView-D6Il3x-6.js
vendored
@@ -1 +0,0 @@
|
|||||||
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-Bk7n1O1W.js";import{a as x}from"./config-provider-CxhqvDI_.js";import{t as S}from"./auth-D6Tj4g1X.js";import{n as C,t as w}from"./DeleteOutlined-CRNedxr9.js";import{t as T}from"./PlusOutlined-CosM7zFe.js";import{t as E}from"./BookOutlined-Canuw-q-.js";var D={class:`page-shell`},O={class:`page-heading`},k={key:0,class:`knowledge-grid`},A={key:0,class:`risk-note`},j=_(i({__name:`KnowledgeView`,setup(i){let _=b(!1),j=b(!1),M=b([]),N=b([]),P=S(),F=d({scenario_id:void 0,title:``,content:``,forbidden:``,risk_note:``}),I=n(()=>N.value.map(e=>({value:e.id,label:e.name})));async function L(){_.value=!0;try{let[e,t]=await Promise.all([v.get(`/knowledge-cards`),v.get(`/scenarios`)]);M.value=e.items,N.value=t.items}catch(e){x.error(g(e))}finally{_.value=!1}}async function R(){try{await v.post(`/knowledge-cards`,{scenario_id:F.scenario_id,title:F.title,content:{standard_copy:F.content,forbidden_copy:F.forbidden,risk_note:F.risk_note}}),x.success(`知识卡已发布`),j.value=!1,Object.assign(F,{scenario_id:void 0,title:``,content:``,forbidden:``,risk_note:``}),await L()}catch(e){x.error(g(e))}}function z(e){C.confirm({title:`归档“${e.title}”?`,okType:`danger`,async onOk(){await v.delete(`/knowledge-cards/${e.id}`),await L()}})}function B(e,t){return e.content?.[t]||``}return u(L),(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`),C=y(`a-textarea`),L=y(`a-form`),V=y(`a-drawer`);return s(),p(`div`,D,[a(`div`,O,[i[8]||=a(`div`,null,[a(`h1`,null,`知识卡`),a(`p`,null,`沉淀经过审核的标准表达、禁用语和风险提醒。`)],-1),l(P).can(`knowledge.edit`)?(s(),m(u,{key:0,type:`primary`,onClick:i[0]||=e=>j.value=!0},{default:h(()=>[r(l(T)),i[7]||=o(`新建知识卡`,-1)]),_:1})):e(``,!0)]),r(v,{spinning:_.value},{default:h(()=>[M.value.length?(s(),p(`div`,k,[(s(!0),p(c,null,f(M.value,n=>(s(),p(`article`,{key:n.id,class:`knowledge-card surface`},[a(`header`,null,[a(`span`,null,[r(l(E))]),r(d,{color:`green`},{default:h(()=>[...i[9]||=[o(`已发布`,-1)]]),_:1})]),a(`h2`,null,t(n.title),1),a(`p`,null,t(B(n,`standard_copy`)||`暂无标准话术`),1),B(n,`risk_note`)?(s(),p(`div`,A,t(B(n,`risk_note`)),1)):e(``,!0),a(`footer`,null,[a(`span`,null,t(N.value.find(e=>e.id===n.scenario_id)?.name||`未分类场景`),1),l(P).can(`knowledge.edit`)?(s(),m(u,{key:0,type:`text`,danger:``,"aria-label":`归档知识卡`,onClick:e=>z(n)},{default:h(()=>[r(l(w))]),_:1},8,[`onClick`])):e(``,!0)])]))),128))])):(s(),m(g,{key:1,description:`还没有知识卡`}))]),_:1},8,[`spinning`]),l(P).can(`knowledge.edit`)?(s(),m(V,{key:0,open:j.value,"onUpdate:open":i[6]||=e=>j.value=e,title:`新建知识卡`,width:`520`},{default:h(()=>[r(L,{layout:`vertical`,model:F,onFinish:R},{default:h(()=>[r(x,{label:`所属场景`,name:`scenario_id`,rules:[{required:!0,message:`请选择场景`}]},{default:h(()=>[r(b,{value:F.scenario_id,"onUpdate:value":i[1]||=e=>F.scenario_id=e,options:I.value},null,8,[`value`,`options`])]),_:1}),r(x,{label:`标题`,name:`title`,rules:[{required:!0,message:`请输入标题`}]},{default:h(()=>[r(S,{value:F.title,"onUpdate:value":i[2]||=e=>F.title=e},null,8,[`value`])]),_:1}),r(x,{label:`标准话术`,name:`content`,rules:[{required:!0,message:`请输入标准话术`}]},{default:h(()=>[r(C,{value:F.content,"onUpdate:value":i[3]||=e=>F.content=e,rows:5},null,8,[`value`])]),_:1}),r(x,{label:`禁用表达`},{default:h(()=>[r(C,{value:F.forbidden,"onUpdate:value":i[4]||=e=>F.forbidden=e,rows:3},null,8,[`value`])]),_:1}),r(x,{label:`风险提醒`},{default:h(()=>[r(C,{value:F.risk_note,"onUpdate:value":i[5]||=e=>F.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`])):e(``,!0)])}}}),[[`__scopeId`,`data-v-68fdbe9b`]]);export{j as default};
|
|
||||||
1
web/dist/assets/KnowledgeView-DgJgbg9L.css
vendored
Normal file
1
web/dist/assets/KnowledgeView-DgJgbg9L.css
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
.knowledge-grid[data-v-eaece4e4]{grid-template-columns:repeat(3,minmax(0,1fr));gap:14px;display:grid}.knowledge-card[data-v-eaece4e4]{flex-direction:column;min-height:300px;padding:20px;display:flex}.knowledge-card header[data-v-eaece4e4]{justify-content:space-between;display:flex}.card-icon[data-v-eaece4e4]{width:36px;height:36px;color:var(--green);background:#e8f3ef;border-radius:4px;place-items:center;display:grid}.knowledge-card h2[data-v-eaece4e4]{margin:20px 0 10px;font-family:Noto Serif SC,serif;font-size:19px}.standard-copy[data-v-eaece4e4]{color:#4f5c56;margin:0;line-height:1.7}.copy-note[data-v-eaece4e4]{border-left:2px solid;grid-template-columns:58px 1fr;gap:8px;margin-top:12px;padding:9px 11px;font-size:12px;display:grid}.copy-note b[data-v-eaece4e4]{font-size:11px}.copy-note.forbidden[data-v-eaece4e4]{color:#7a4d4d;background:#faf0f0;border-color:#ba7474}.copy-note.risk[data-v-eaece4e4]{color:#7f591c;background:#fbf3e5;border-color:#c68a37}.knowledge-card footer[data-v-eaece4e4]{color:var(--muted);justify-content:space-between;align-items:center;margin-top:auto;padding-top:18px;font-size:12px;display:flex}.card-actions[data-v-eaece4e4]{display:flex}.version-row[data-v-eaece4e4]{border-bottom:1px solid var(--line);padding:18px 0}.version-row[data-v-eaece4e4]:first-child{padding-top:0}.version-head[data-v-eaece4e4]{align-items:center;gap:8px;display:flex}.version-head time[data-v-eaece4e4]{color:var(--muted);margin-left:auto;font-size:11px}.version-row>p[data-v-eaece4e4]{color:#45524c;margin:12px 0;line-height:1.7}.history-note[data-v-eaece4e4]{color:#6f7b76;margin-top:7px;font-size:12px}.history-note b[data-v-eaece4e4]{color:#39463f;margin-right:8px}@media (width<=1050px){.knowledge-grid[data-v-eaece4e4]{grid-template-columns:repeat(2,1fr)}}@media (width<=650px){.knowledge-grid[data-v-eaece4e4]{grid-template-columns:1fr}.version-head[data-v-eaece4e4]{flex-wrap:wrap}.version-head time[data-v-eaece4e4]{width:100%;margin-left:0}}
|
||||||
1
web/dist/assets/MembersView-6hfD_lrO.js
vendored
Normal file
1
web/dist/assets/MembersView-6hfD_lrO.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
web/dist/assets/MembersView-DbeUaVMS.js
vendored
1
web/dist/assets/MembersView-DbeUaVMS.js
vendored
File diff suppressed because one or more lines are too long
1
web/dist/assets/SOPEditorView-C-aX-vfz.css
vendored
Normal file
1
web/dist/assets/SOPEditorView-C-aX-vfz.css
vendored
Normal file
File diff suppressed because one or more lines are too long
1
web/dist/assets/SOPEditorView-CuevD5je.css
vendored
1
web/dist/assets/SOPEditorView-CuevD5je.css
vendored
@@ -1 +0,0 @@
|
|||||||
.editor-page[data-v-d8fcde6e]{background:#eef1ef;min-height:calc(100vh - 62px)}.editor-header[data-v-d8fcde6e]{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-d8fcde6e]{align-items:center;gap:8px;display:flex}.editor-title>div[data-v-d8fcde6e]{flex-direction:column;display:flex}.editor-title b[data-v-d8fcde6e]{font-size:15px}.editor-title span[data-v-d8fcde6e]{color:var(--muted);margin-top:3px;font-size:11px}.editor-workbench[data-v-d8fcde6e]{grid-template-columns:280px minmax(0,1fr);gap:16px;max-width:1500px;margin:0 auto;padding:18px;display:grid}.node-rail[data-v-d8fcde6e]{align-self:start;position:sticky;top:146px;overflow:hidden}.rail-title[data-v-d8fcde6e]{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-d8fcde6e]{padding:8px}.node-list button[data-v-d8fcde6e]{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-d8fcde6e]:hover{background:#f5f8f6}.node-list button.active[data-v-d8fcde6e]{background:#edf6f2;border-color:#b9d8cc}.node-index[data-v-d8fcde6e]{color:#88958f;font-family:monospace;font-size:11px}.node-meta[data-v-d8fcde6e]{flex-direction:column;min-width:0;display:flex}.node-meta b[data-v-d8fcde6e]{text-overflow:ellipsis;white-space:nowrap;font-size:13px;overflow:hidden}.node-meta small[data-v-d8fcde6e]{color:var(--muted);margin-top:3px;font-size:11px}.node-move[data-v-d8fcde6e]{color:#87938e;gap:4px;display:flex}.node-move[data-v-d8fcde6e]>:hover{color:var(--green)}.node-editor[data-v-d8fcde6e]{align-self:start;min-height:600px;padding:24px}.node-editor-head[data-v-d8fcde6e]{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-d8fcde6e]{margin:6px 0 0;font-family:Noto Serif SC,serif;font-size:22px}.node-type-mark[data-v-d8fcde6e]{color:var(--green);font-size:10px;font-weight:750}.property-form[data-v-d8fcde6e]{padding-top:22px}.property-grid[data-v-d8fcde6e]{grid-template-columns:1fr 1fr;gap:16px;display:grid}.transition-head[data-v-d8fcde6e]{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-d8fcde6e]{flex-direction:column;display:flex}.transition-head span[data-v-d8fcde6e]{color:var(--muted);margin-top:4px;font-size:11px}.transition-row[data-v-d8fcde6e]{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-d8fcde6e]{background:var(--green);width:16px;height:1px}.route-target[data-v-d8fcde6e]{font-weight:600}.route-empty[data-v-d8fcde6e]{color:var(--muted);padding:20px 0;font-size:12px}.edge-builder[data-v-d8fcde6e]{grid-template-columns:1.1fr 1fr 120px 1fr auto;gap:8px;padding-top:16px;display:grid}.editor-empty[data-v-d8fcde6e]{place-items:center;min-height:400px;display:grid}@media (width<=1050px){.edge-builder[data-v-d8fcde6e]{grid-template-columns:1fr 1fr}.editor-workbench[data-v-d8fcde6e]{grid-template-columns:230px minmax(0,1fr)}}@media (width<=760px){.editor-header[data-v-d8fcde6e]{flex-direction:column;align-items:flex-start;padding:12px;top:62px}.editor-workbench[data-v-d8fcde6e]{grid-template-columns:1fr;padding:10px}.node-rail[data-v-d8fcde6e]{position:static}.property-grid[data-v-d8fcde6e],.edge-builder[data-v-d8fcde6e]{grid-template-columns:1fr}.node-editor[data-v-d8fcde6e]{padding:16px}.transition-head[data-v-d8fcde6e]{margin:8px -16px 0;padding:16px}}
|
|
||||||
3
web/dist/assets/SOPEditorView-D6Kadvbx.js
vendored
Normal file
3
web/dist/assets/SOPEditorView-D6Kadvbx.js
vendored
Normal file
File diff suppressed because one or more lines are too long
3
web/dist/assets/SOPEditorView-DbfakLxj.js
vendored
3
web/dist/assets/SOPEditorView-DbfakLxj.js
vendored
File diff suppressed because one or more lines are too long
1
web/dist/assets/ScenarioDetailView-BuVlg5Lu.css
vendored
Normal file
1
web/dist/assets/ScenarioDetailView-BuVlg5Lu.css
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
.back-link[data-v-fb83e22e]{color:var(--muted);margin:-4px 0 12px -14px}.detail-heading[data-v-fb83e22e]{color:#fff;background:#202825;border-radius:6px;justify-content:space-between;align-items:flex-end;gap:24px;min-height:180px;padding:28px;display:flex}.scenario-summary[data-v-fb83e22e]{min-width:0}.detail-heading h1[data-v-fb83e22e]{letter-spacing:0;margin:10px 0 8px;font-family:Noto Serif SC,serif;font-size:28px}.detail-heading p[data-v-fb83e22e]{color:#c3cec9;max-width:720px;margin:0}.heading-tags[data-v-fb83e22e]{color:#a8b6b0;align-items:center;gap:10px;font-size:12px;display:flex}.trigger-copy[data-v-fb83e22e]{color:#aab8b2;max-width:800px;margin-top:14px;font-size:12px}.trigger-copy span[data-v-fb83e22e]{color:#68c6a6;margin-right:9px;font-weight:700}.heading-actions[data-v-fb83e22e]{flex-direction:column;justify-content:space-between;align-self:stretch;align-items:flex-end;display:flex}.heading-actions>div[data-v-fb83e22e]{gap:8px;display:flex}.scenario-code[data-v-fb83e22e]{color:#70817a;font-size:12px}.detail-tabs[data-v-fb83e22e]{margin-top:18px}.toolbar>div[data-v-fb83e22e]{align-items:baseline;gap:12px;display:flex}.toolbar-note[data-v-fb83e22e]{color:var(--muted);font-size:12px}.field-grid[data-v-fb83e22e]{grid-template-columns:1fr 1fr;gap:14px;display:grid}.row-actions[data-v-fb83e22e]{display:flex}code[data-v-fb83e22e]{color:#17654f;background:#eef5f2;border-radius:3px;padding:2px 5px}.sop-list button[data-v-fb83e22e]{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-fb83e22e]:hover{background:#f8faf9}.sop-icon[data-v-fb83e22e]{width:38px;height:38px;color:var(--green);background:#e8f3ef;border-radius:4px;place-items:center;display:grid}.sop-copy[data-v-fb83e22e]{flex-direction:column;min-width:0;display:flex}.sop-copy small[data-v-fb83e22e]{color:var(--muted);text-overflow:ellipsis;white-space:nowrap;margin-top:4px;overflow:hidden}.sop-date[data-v-fb83e22e]{color:var(--muted);text-align:right;font-size:12px}.empty-state[data-v-fb83e22e]{color:#82908a;text-align:center;padding:64px 20px}.empty-state>span[data-v-fb83e22e]{font-size:32px}.empty-state p[data-v-fb83e22e]{margin:12px 0 0}@media (width<=650px){.detail-heading[data-v-fb83e22e]{flex-direction:column;align-items:flex-start;padding:20px}.heading-actions[data-v-fb83e22e]{align-items:flex-start;gap:14px;width:100%}.scenario-code[data-v-fb83e22e]{display:none}.field-grid[data-v-fb83e22e]{grid-template-columns:1fr;gap:0}.sop-list button[data-v-fb83e22e]{grid-template-columns:36px 1fr auto}.sop-date[data-v-fb83e22e],.toolbar-note[data-v-fb83e22e]{display:none}}
|
||||||
3
web/dist/assets/ScenarioDetailView-CN8bGR8j.js
vendored
Normal file
3
web/dist/assets/ScenarioDetailView-CN8bGR8j.js
vendored
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
|
|||||||
.back-link[data-v-f755c473]{color:var(--muted);margin:-4px 0 12px -14px}.detail-heading[data-v-f755c473]{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-f755c473]{letter-spacing:0;margin:10px 0 8px;font-family:Noto Serif SC,serif;font-size:28px}.detail-heading p[data-v-f755c473]{color:#b8c4bf;max-width:720px;margin:0}.heading-tags[data-v-f755c473]{color:#a8b6b0;align-items:center;gap:10px;font-size:12px;display:flex}.scenario-code[data-v-f755c473]{color:#70817a;align-self:flex-start;font-size:12px}.detail-tabs[data-v-f755c473]{margin-top:18px}.toolbar>div[data-v-f755c473]{align-items:baseline;gap:12px;display:flex}.toolbar-note[data-v-f755c473]{color:var(--muted);font-size:12px}.field-grid[data-v-f755c473]{grid-template-columns:1fr 1fr;gap:14px;display:grid}code[data-v-f755c473]{color:#17654f;background:#eef5f2;border-radius:3px;padding:2px 5px}.sop-list button[data-v-f755c473]{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-f755c473]:hover{background:#f8faf9}.sop-icon[data-v-f755c473]{width:38px;height:38px;color:var(--green);background:#e8f3ef;border-radius:4px;place-items:center;display:grid}.sop-copy[data-v-f755c473]{flex-direction:column;min-width:0;display:flex}.sop-copy small[data-v-f755c473]{color:var(--muted);text-overflow:ellipsis;white-space:nowrap;margin-top:4px;overflow:hidden}.sop-date[data-v-f755c473]{color:var(--muted);text-align:right;font-size:12px}.empty-state[data-v-f755c473]{color:#82908a;text-align:center;padding:64px 20px}.empty-state>span[data-v-f755c473]{font-size:32px}.empty-state p[data-v-f755c473]{margin:12px 0 0}@media (width<=650px){.detail-heading[data-v-f755c473]{flex-direction:column;align-items:flex-start}.scenario-code[data-v-f755c473]{display:none}.field-grid[data-v-f755c473]{grid-template-columns:1fr;gap:0}.sop-list button[data-v-f755c473]{grid-template-columns:36px 1fr auto}.sop-date[data-v-f755c473]{display:none}}
|
|
||||||
1
web/dist/assets/ScenariosView-CVjBqUT9.css
vendored
Normal file
1
web/dist/assets/ScenariosView-CVjBqUT9.css
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
.scenario-name[data-v-22a9544f]{cursor:pointer;flex-direction:column;max-width:460px;display:flex}.scenario-name b[data-v-22a9544f]{font-size:14px}.scenario-name small[data-v-22a9544f]{color:var(--muted);text-overflow:ellipsis;white-space:nowrap;margin-top:4px;overflow:hidden}.form-grid[data-v-22a9544f]{grid-template-columns:1fr 1fr;gap:14px;display:grid}[data-v-22a9544f] .ant-table-row{cursor:pointer}@media (width<=600px){.form-grid[data-v-22a9544f]{grid-template-columns:1fr;gap:0}}
|
||||||
File diff suppressed because one or more lines are too long
1
web/dist/assets/ScenariosView-DZA-xIbV.css
vendored
1
web/dist/assets/ScenariosView-DZA-xIbV.css
vendored
@@ -1 +0,0 @@
|
|||||||
.scenario-name[data-v-4642c539]{cursor:pointer;flex-direction:column;max-width:460px;display:flex}.scenario-name b[data-v-4642c539]{font-size:14px}.scenario-name small[data-v-4642c539]{color:var(--muted);text-overflow:ellipsis;white-space:nowrap;margin-top:4px;overflow:hidden}.form-grid[data-v-4642c539]{grid-template-columns:1fr 1fr;gap:14px;display:grid}[data-v-4642c539] .ant-table-row{cursor:pointer}@media (width<=600px){.form-grid[data-v-4642c539]{grid-template-columns:1fr;gap:0}}
|
|
||||||
1
web/dist/assets/TeamOutlined-BjyYKMen.js
vendored
1
web/dist/assets/TeamOutlined-BjyYKMen.js
vendored
@@ -1 +0,0 @@
|
|||||||
import{F as e,a as t}from"./client-Bk7n1O1W.js";var n={icon:{tag:`svg`,attrs:{viewBox:`64 64 896 896`,focusable:`false`},children:[{tag:`path`,attrs:{d:`M257.7 752c2 0 4-.2 6-.5L431.9 722c2-.4 3.9-1.3 5.3-2.8l423.9-423.9a9.96 9.96 0 000-14.1L694.9 114.9c-1.9-1.9-4.4-2.9-7.1-2.9s-5.2 1-7.1 2.9L256.8 538.8c-1.5 1.5-2.4 3.3-2.8 5.3l-29.5 168.2a33.5 33.5 0 009.4 29.8c6.6 6.4 14.9 9.9 23.8 9.9zm67.4-174.4L687.8 215l73.3 73.3-362.7 362.6-88.9 15.7 15.6-89zM880 836H144c-17.7 0-32 14.3-32 32v36c0 4.4 3.6 8 8 8h784c4.4 0 8-3.6 8-8v-36c0-17.7-14.3-32-32-32z`}}]},name:`edit`,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=`EditOutlined`,a.inheritAttrs=!1;var o={icon:{tag:`svg`,attrs:{viewBox:`64 64 896 896`,focusable:`false`},children:[{tag:`path`,attrs:{d:`M824.2 699.9a301.55 301.55 0 00-86.4-60.4C783.1 602.8 812 546.8 812 484c0-110.8-92.4-201.7-203.2-200-109.1 1.7-197 90.6-197 200 0 62.8 29 118.8 74.2 155.5a300.95 300.95 0 00-86.4 60.4C345 754.6 314 826.8 312 903.8a8 8 0 008 8.2h56c4.3 0 7.9-3.4 8-7.7 1.9-58 25.4-112.3 66.7-153.5A226.62 226.62 0 01612 684c60.9 0 118.2 23.7 161.3 66.8C814.5 792 838 846.3 840 904.3c.1 4.3 3.7 7.7 8 7.7h56a8 8 0 008-8.2c-2-77-33-149.2-87.8-203.9zM612 612c-34.2 0-66.4-13.3-90.5-37.5a126.86 126.86 0 01-37.5-91.8c.3-32.8 13.4-64.5 36.3-88 24-24.6 56.1-38.3 90.4-38.7 33.9-.3 66.8 12.9 91 36.6 24.8 24.3 38.4 56.8 38.4 91.4 0 34.2-13.3 66.3-37.5 90.5A127.3 127.3 0 01612 612zM361.5 510.4c-.9-8.7-1.4-17.5-1.4-26.4 0-15.9 1.5-31.4 4.3-46.5.7-3.6-1.2-7.3-4.5-8.8-13.6-6.1-26.1-14.5-36.9-25.1a127.54 127.54 0 01-38.7-95.4c.9-32.1 13.8-62.6 36.3-85.6 24.7-25.3 57.9-39.1 93.2-38.7 31.9.3 62.7 12.6 86 34.4 7.9 7.4 14.7 15.6 20.4 24.4 2 3.1 5.9 4.4 9.3 3.2 17.6-6.1 36.2-10.4 55.3-12.4 5.6-.6 8.8-6.6 6.3-11.6-32.5-64.3-98.9-108.7-175.7-109.9-110.9-1.7-203.3 89.2-203.3 199.9 0 62.8 28.9 118.8 74.2 155.5-31.8 14.7-61.1 35-86.5 60.4-54.8 54.7-85.8 126.9-87.8 204a8 8 0 008 8.2h56.1c4.3 0 7.9-3.4 8-7.7 1.9-58 25.4-112.3 66.7-153.5 29.4-29.4 65.4-49.8 104.7-59.7 3.9-1 6.5-4.7 6-8.7z`}}]},name:`team`,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=`TeamOutlined`,l.inheritAttrs=!1;export{a as n,l as t};
|
|
||||||
1
web/dist/assets/TeamOutlined-DCK_-ckK.js
vendored
Normal file
1
web/dist/assets/TeamOutlined-DCK_-ckK.js
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
import{F as e,a as t}from"./client-Bk7n1O1W.js";var n={icon:{tag:`svg`,attrs:{viewBox:`64 64 896 896`,focusable:`false`},children:[{tag:`path`,attrs:{d:`M824.2 699.9a301.55 301.55 0 00-86.4-60.4C783.1 602.8 812 546.8 812 484c0-110.8-92.4-201.7-203.2-200-109.1 1.7-197 90.6-197 200 0 62.8 29 118.8 74.2 155.5a300.95 300.95 0 00-86.4 60.4C345 754.6 314 826.8 312 903.8a8 8 0 008 8.2h56c4.3 0 7.9-3.4 8-7.7 1.9-58 25.4-112.3 66.7-153.5A226.62 226.62 0 01612 684c60.9 0 118.2 23.7 161.3 66.8C814.5 792 838 846.3 840 904.3c.1 4.3 3.7 7.7 8 7.7h56a8 8 0 008-8.2c-2-77-33-149.2-87.8-203.9zM612 612c-34.2 0-66.4-13.3-90.5-37.5a126.86 126.86 0 01-37.5-91.8c.3-32.8 13.4-64.5 36.3-88 24-24.6 56.1-38.3 90.4-38.7 33.9-.3 66.8 12.9 91 36.6 24.8 24.3 38.4 56.8 38.4 91.4 0 34.2-13.3 66.3-37.5 90.5A127.3 127.3 0 01612 612zM361.5 510.4c-.9-8.7-1.4-17.5-1.4-26.4 0-15.9 1.5-31.4 4.3-46.5.7-3.6-1.2-7.3-4.5-8.8-13.6-6.1-26.1-14.5-36.9-25.1a127.54 127.54 0 01-38.7-95.4c.9-32.1 13.8-62.6 36.3-85.6 24.7-25.3 57.9-39.1 93.2-38.7 31.9.3 62.7 12.6 86 34.4 7.9 7.4 14.7 15.6 20.4 24.4 2 3.1 5.9 4.4 9.3 3.2 17.6-6.1 36.2-10.4 55.3-12.4 5.6-.6 8.8-6.6 6.3-11.6-32.5-64.3-98.9-108.7-175.7-109.9-110.9-1.7-203.3 89.2-203.3 199.9 0 62.8 28.9 118.8 74.2 155.5-31.8 14.7-61.1 35-86.5 60.4-54.8 54.7-85.8 126.9-87.8 204a8 8 0 008 8.2h56.1c4.3 0 7.9-3.4 8-7.7 1.9-58 25.4-112.3 66.7-153.5 29.4-29.4 65.4-49.8 104.7-59.7 3.9-1 6.5-4.7 6-8.7z`}}]},name:`team`,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=`TeamOutlined`,a.inheritAttrs=!1;export{a as t};
|
||||||
File diff suppressed because one or more lines are too long
6
web/dist/index.html
vendored
6
web/dist/index.html
vendored
@@ -5,7 +5,7 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<meta name="theme-color" content="#202825" />
|
<meta name="theme-color" content="#202825" />
|
||||||
<title>销冠 SOP 平台</title>
|
<title>销冠 SOP 平台</title>
|
||||||
<script type="module" crossorigin src="/assets/index-BNEb9LqG.js"></script>
|
<script type="module" crossorigin src="/assets/index-BeO8QXu8.js"></script>
|
||||||
<link rel="modulepreload" crossorigin href="/assets/client-Bk7n1O1W.js">
|
<link rel="modulepreload" crossorigin href="/assets/client-Bk7n1O1W.js">
|
||||||
<link rel="modulepreload" crossorigin href="/assets/config-provider-CxhqvDI_.js">
|
<link rel="modulepreload" crossorigin href="/assets/config-provider-CxhqvDI_.js">
|
||||||
<link rel="modulepreload" crossorigin href="/assets/auth-D6Tj4g1X.js">
|
<link rel="modulepreload" crossorigin href="/assets/auth-D6Tj4g1X.js">
|
||||||
@@ -16,9 +16,11 @@
|
|||||||
<link rel="modulepreload" crossorigin href="/assets/PlusOutlined-CosM7zFe.js">
|
<link rel="modulepreload" crossorigin href="/assets/PlusOutlined-CosM7zFe.js">
|
||||||
<link rel="modulepreload" crossorigin href="/assets/ArrowLeftOutlined-B1KoeknA.js">
|
<link rel="modulepreload" crossorigin href="/assets/ArrowLeftOutlined-B1KoeknA.js">
|
||||||
<link rel="modulepreload" crossorigin href="/assets/AppstoreOutlined-BD2QJkU0.js">
|
<link rel="modulepreload" crossorigin href="/assets/AppstoreOutlined-BD2QJkU0.js">
|
||||||
<link rel="modulepreload" crossorigin href="/assets/TeamOutlined-BjyYKMen.js">
|
<link rel="modulepreload" crossorigin href="/assets/EditOutlined-BIUW-zYI.js">
|
||||||
<link rel="modulepreload" crossorigin href="/assets/BookOutlined-Canuw-q-.js">
|
<link rel="modulepreload" crossorigin href="/assets/BookOutlined-Canuw-q-.js">
|
||||||
|
<link rel="modulepreload" crossorigin href="/assets/HistoryOutlined-ABEqcIfz.js">
|
||||||
<link rel="modulepreload" crossorigin href="/assets/PlayCircleOutlined-DjeS67m_.js">
|
<link rel="modulepreload" crossorigin href="/assets/PlayCircleOutlined-DjeS67m_.js">
|
||||||
|
<link rel="modulepreload" crossorigin href="/assets/TeamOutlined-DCK_-ckK.js">
|
||||||
<link rel="modulepreload" crossorigin href="/assets/useApi-CROJJdhE-C8LJOf_8.js">
|
<link rel="modulepreload" crossorigin href="/assets/useApi-CROJJdhE-C8LJOf_8.js">
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-D6NKEiQp.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-D6NKEiQp.css">
|
||||||
</head>
|
</head>
|
||||||
|
|||||||
@@ -3,11 +3,12 @@ export interface User { user_id: number; tenant_id: number; role_code: string; p
|
|||||||
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 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 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 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 SOPVersion extends BaseEntity { sop_id: number; version: number; status: string; start_node_key: string; published_at?: string; created_by?:number; reviewed_by?:number; creator_name?:string; reviewer_name?: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 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 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 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> }
|
export interface KnowledgeCard extends BaseEntity { scenario_id: number; title: string; status: string; version?: number; content?: Record<string, any> }
|
||||||
|
export interface KnowledgeCardVersion extends BaseEntity { knowledge_card_id:number; version:number; status:string; content:Record<string,any> }
|
||||||
export interface ReviewItem { sop_id:number; sop_name:string; description:string; scenario_id:number; scenario_name:string; version_id:number; version:number; creator_id:number; creator_name:string; submitted_at:string }
|
export interface ReviewItem { sop_id:number; sop_name:string; description:string; scenario_id:number; scenario_name:string; version_id:number; version:number; creator_id:number; creator_name:string; submitted_at:string }
|
||||||
export interface RunEvent extends BaseEntity { run_id:number; node_key:string; action:string; payload:Record<string,any>; node_title:string; node_type:string; node_content:string }
|
export interface RunEvent extends BaseEntity { run_id:number; node_key:string; action:string; payload:Record<string,any>; node_title:string; node_type:string; node_content:string }
|
||||||
export interface RunFeedback extends BaseEntity { run_id:number; user_id:number; score:number; comment:string; user_name:string }
|
export interface RunFeedback extends BaseEntity { run_id:number; user_id:number; score:number; comment:string; user_name:string }
|
||||||
|
|||||||
@@ -1,19 +1,138 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, reactive, ref } from 'vue'
|
import { computed, onMounted, reactive, ref } from 'vue'
|
||||||
import { BookOutlined, DeleteOutlined, PlusOutlined } from '@ant-design/icons-vue'
|
import { BookOutlined, DeleteOutlined, EditOutlined, HistoryOutlined, PlusOutlined } from '@ant-design/icons-vue'
|
||||||
import { message, Modal } from 'ant-design-vue'
|
import { message, Modal } from 'ant-design-vue'
|
||||||
import { api, apiMessage } from '@/api/client'
|
import { api, apiMessage } from '@/api/client'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
import type { KnowledgeCard, Scenario } from '@/types'
|
import type { KnowledgeCard, KnowledgeCardVersion, Scenario } from '@/types'
|
||||||
const loading=ref(false);const open=ref(false);const items=ref<KnowledgeCard[]>([]);const scenarios=ref<Scenario[]>([])
|
|
||||||
|
const loading = ref(false)
|
||||||
|
const saving = ref(false)
|
||||||
|
const open = ref(false)
|
||||||
|
const historyOpen = ref(false)
|
||||||
|
const editingId = ref<number | null>(null)
|
||||||
|
const historyCard = ref<KnowledgeCard | null>(null)
|
||||||
|
const items = ref<KnowledgeCard[]>([])
|
||||||
|
const versions = ref<KnowledgeCardVersion[]>([])
|
||||||
|
const scenarios = ref<Scenario[]>([])
|
||||||
const auth = useAuthStore()
|
const auth = useAuthStore()
|
||||||
const form = reactive({ scenario_id: undefined as number | undefined, title: '', content: '', forbidden: '', risk_note: '' })
|
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})))
|
const scenarioOptions = computed(() => scenarios.value.map(item => ({ value: item.id, label: item.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}}
|
const drawerTitle = computed(() => editingId.value ? '编辑知识卡' : '新建知识卡')
|
||||||
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()}})}
|
async function load() {
|
||||||
function contentOf(item:KnowledgeCard,key:string){return item.content?.[key]||''}
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetForm() {
|
||||||
|
editingId.value = null
|
||||||
|
Object.assign(form, { scenario_id: undefined, title: '', content: '', forbidden: '', risk_note: '' })
|
||||||
|
}
|
||||||
|
|
||||||
|
function openCreate() {
|
||||||
|
resetForm()
|
||||||
|
open.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEdit(item: KnowledgeCard) {
|
||||||
|
editingId.value = item.id
|
||||||
|
Object.assign(form, {
|
||||||
|
scenario_id: item.scenario_id, title: item.title, content: contentOf(item, 'standard_copy'),
|
||||||
|
forbidden: contentOf(item, 'forbidden_copy'), risk_note: contentOf(item, 'risk_note'),
|
||||||
|
})
|
||||||
|
open.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
saving.value = true
|
||||||
|
const content = { standard_copy: form.content, forbidden_copy: form.forbidden, risk_note: form.risk_note }
|
||||||
|
try {
|
||||||
|
if (editingId.value) await api.put(`/knowledge-cards/${editingId.value}`, { title: form.title, content })
|
||||||
|
else await api.post('/knowledge-cards', { scenario_id: form.scenario_id, title: form.title, content })
|
||||||
|
message.success(editingId.value ? '知识卡新版本已发布' : '知识卡已发布')
|
||||||
|
open.value = false
|
||||||
|
resetForm()
|
||||||
|
await load()
|
||||||
|
} catch (error) {
|
||||||
|
message.error(apiMessage(error))
|
||||||
|
} finally {
|
||||||
|
saving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function showHistory(item: KnowledgeCard) {
|
||||||
|
historyCard.value = item
|
||||||
|
try {
|
||||||
|
const data = await api.get<{ items: KnowledgeCardVersion[] }>(`/knowledge-cards/${item.id}/versions`)
|
||||||
|
versions.value = data.items || []
|
||||||
|
historyOpen.value = true
|
||||||
|
} catch (error) { message.error(apiMessage(error)) }
|
||||||
|
}
|
||||||
|
|
||||||
|
function remove(item: KnowledgeCard) {
|
||||||
|
Modal.confirm({
|
||||||
|
title: `归档“${item.title}”?`, content: '已发布或历史 SOP 版本引用的知识卡不能归档。', okText: '归档知识卡', okType: 'danger',
|
||||||
|
async onOk() {
|
||||||
|
try { await api.delete(`/knowledge-cards/${item.id}`); message.success('知识卡已归档'); await load() }
|
||||||
|
catch (error) { message.error(apiMessage(error)) }
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function contentOf(item: { content?: Record<string, any> }, key: string) { return String(item.content?.[key] || '') }
|
||||||
onMounted(load)
|
onMounted(load)
|
||||||
</script>
|
</script>
|
||||||
<template><div class="page-shell"><div class="page-heading"><div><h1>知识卡</h1><p>沉淀经过审核的标准表达、禁用语和风险提醒。</p></div><a-button v-if="auth.can('knowledge.edit')" 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 v-if="auth.can('knowledge.edit')" type="text" danger aria-label="归档知识卡" @click="remove(item)"><DeleteOutlined /></a-button></footer></article></div><a-empty v-else description="还没有知识卡" /></a-spin><a-drawer v-if="auth.can('knowledge.edit')" 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>
|
<template>
|
||||||
|
<div class="page-shell">
|
||||||
|
<div class="page-heading"><div><h1>知识卡</h1><p>沉淀经过审核的标准表达、禁用语和风险提醒。</p></div><a-button v-if="auth.can('knowledge.edit')" type="primary" @click="openCreate"><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 class="card-icon"><BookOutlined /></span><div><a-tag color="green">已发布</a-tag><a-tag>V{{ item.version || 1 }}</a-tag></div></header>
|
||||||
|
<h2>{{ item.title }}</h2>
|
||||||
|
<p class="standard-copy">{{ contentOf(item, 'standard_copy') || '暂无标准话术' }}</p>
|
||||||
|
<div v-if="contentOf(item, 'forbidden_copy')" class="copy-note forbidden"><b>禁用表达</b><span>{{ contentOf(item, 'forbidden_copy') }}</span></div>
|
||||||
|
<div v-if="contentOf(item, 'risk_note')" class="copy-note risk"><b>风险提醒</b><span>{{ contentOf(item, 'risk_note') }}</span></div>
|
||||||
|
<footer><span>{{ scenarios.find(scene => scene.id === item.scenario_id)?.name || '未分类场景' }}</span><div class="card-actions"><a-tooltip title="版本历史"><a-button type="text" aria-label="查看版本历史" @click="showHistory(item)"><HistoryOutlined /></a-button></a-tooltip><a-tooltip v-if="auth.can('knowledge.edit')" title="编辑知识卡"><a-button type="text" aria-label="编辑知识卡" @click="openEdit(item)"><EditOutlined /></a-button></a-tooltip><a-tooltip v-if="auth.can('knowledge.edit')" title="归档知识卡"><a-button type="text" danger aria-label="归档知识卡" @click="remove(item)"><DeleteOutlined /></a-button></a-tooltip></div></footer>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
<a-empty v-else description="还没有知识卡" />
|
||||||
|
</a-spin>
|
||||||
|
|
||||||
|
<a-drawer v-if="auth.can('knowledge.edit')" v-model:open="open" :title="drawerTitle" width="520" @close="resetForm">
|
||||||
|
<a-form layout="vertical" :model="form" @finish="save">
|
||||||
|
<a-form-item label="所属场景" name="scenario_id" :rules="[{required:true,message:'请选择场景'}]"><a-select v-model:value="form.scenario_id" :disabled="Boolean(editingId)" :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 :loading="saving">{{ editingId ? '发布新版本' : '发布知识卡' }}</a-button>
|
||||||
|
</a-form>
|
||||||
|
</a-drawer>
|
||||||
|
|
||||||
|
<a-drawer v-model:open="historyOpen" :title="`${historyCard?.title || '知识卡'} · 版本历史`" width="560">
|
||||||
|
<div class="version-list">
|
||||||
|
<section v-for="item in versions" :key="item.id" class="version-row">
|
||||||
|
<div class="version-head"><b>V{{ item.version }}</b><a-tag :color="item.status === 'published' ? 'green' : 'default'">{{ item.status === 'published' ? '当前版本' : '历史版本' }}</a-tag><time>{{ new Date(item.created_at).toLocaleString('zh-CN') }}</time></div>
|
||||||
|
<p>{{ contentOf(item, 'standard_copy') }}</p>
|
||||||
|
<div v-if="contentOf(item, 'forbidden_copy')" class="history-note"><b>禁用表达</b>{{ contentOf(item, 'forbidden_copy') }}</div>
|
||||||
|
<div v-if="contentOf(item, 'risk_note')" class="history-note"><b>风险提醒</b>{{ contentOf(item, 'risk_note') }}</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</a-drawer>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.knowledge-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:14px}.knowledge-card{min-height:300px;padding:20px;display:flex;flex-direction:column}.knowledge-card header{display:flex;justify-content:space-between}.card-icon{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}.standard-copy{margin:0;color:#4f5c56;line-height:1.7}.copy-note{display:grid;grid-template-columns:58px 1fr;gap:8px;margin-top:12px;padding:9px 11px;font-size:12px;border-left:2px solid}.copy-note b{font-size:11px}.copy-note.forbidden{color:#7a4d4d;background:#faf0f0;border-color:#ba7474}.copy-note.risk{color:#7f591c;background:#fbf3e5;border-color:#c68a37}.knowledge-card footer{display:flex;align-items:center;justify-content:space-between;margin-top:auto;padding-top:18px;color:var(--muted);font-size:12px}.card-actions{display:flex}.version-row{padding:18px 0;border-bottom:1px solid var(--line)}.version-row:first-child{padding-top:0}.version-head{display:flex;align-items:center;gap:8px}.version-head time{margin-left:auto;color:var(--muted);font-size:11px}.version-row>p{margin:12px 0;color:#45524c;line-height:1.7}.history-note{margin-top:7px;color:#6f7b76;font-size:12px}.history-note b{margin-right:8px;color:#39463f}@media(max-width:1050px){.knowledge-grid{grid-template-columns:repeat(2,1fr)}}@media(max-width:650px){.knowledge-grid{grid-template-columns:1fr}.version-head{flex-wrap:wrap}.version-head time{width:100%;margin-left:0}}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, reactive, ref } from 'vue'
|
import { computed, onMounted, reactive, ref } from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import { ArrowDownOutlined, ArrowLeftOutlined, ArrowUpOutlined, DeleteOutlined, PlusOutlined, SaveOutlined, SendOutlined } from '@ant-design/icons-vue'
|
import { ArrowDownOutlined, ArrowLeftOutlined, ArrowUpOutlined, DeleteOutlined, HistoryOutlined, PlusOutlined, RollbackOutlined, SaveOutlined, SendOutlined } from '@ant-design/icons-vue'
|
||||||
import { message, Modal } from 'ant-design-vue'
|
import { message, Modal } from 'ant-design-vue'
|
||||||
import { api, apiMessage } from '@/api/client'
|
import { api, apiMessage } from '@/api/client'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
@@ -12,26 +12,34 @@ interface KnowledgeCard { id:number; scenario_id:number; title:string }
|
|||||||
const route = useRoute(); const router = useRouter(); const id = Number(route.params.id)
|
const route = useRoute(); const router = useRouter(); const id = Number(route.params.id)
|
||||||
const auth = useAuthStore()
|
const auth = useAuthStore()
|
||||||
const loading = ref(true); const saving = ref(false)
|
const loading = ref(true); const saving = ref(false)
|
||||||
|
const historyOpen = ref(false); const rollingBack = ref(false)
|
||||||
const sop = ref<SOP | null>(null); const version = ref<SOPVersion | null>(null)
|
const sop = ref<SOP | null>(null); const version = ref<SOPVersion | null>(null)
|
||||||
|
const versions = ref<SOPVersion[]>([])
|
||||||
const nodes = ref<SOPNode[]>([]); const edges = ref<SOPEdge[]>([]); const selectedKey = ref('')
|
const nodes = ref<SOPNode[]>([]); const edges = ref<SOPEdge[]>([]); const selectedKey = ref('')
|
||||||
const fields = ref<ScenarioField[]>([]); const knowledgeCards = ref<KnowledgeCard[]>([])
|
const fields = ref<ScenarioField[]>([]); const knowledgeCards = ref<KnowledgeCard[]>([])
|
||||||
const selected = computed(() => nodes.value.find(n => n.node_key === selectedKey.value))
|
const selected = computed(() => nodes.value.find(n => n.node_key === selectedKey.value))
|
||||||
const editable = computed(() => version.value?.status === 'draft')
|
const editable = computed(() => version.value?.status === 'draft')
|
||||||
const canEdit = computed(() => editable.value&&auth.can('sop.edit'))
|
const canEdit = computed(() => editable.value&&auth.can('sop.edit'))
|
||||||
const reviewing = computed(() => version.value?.status === 'reviewing')
|
const reviewing = computed(() => version.value?.status === 'reviewing')
|
||||||
|
const isLatestVersion = computed(() => version.value?.version === Math.max(0, ...versions.value.map(item => item.version)))
|
||||||
|
const hasPendingVersion = computed(() => versions.value.some(item => ['draft','reviewing'].includes(item.status)))
|
||||||
const nodeTypes = [{value:'message',label:'标准话术'},{value:'question',label:'单项提问'},{value:'form',label:'信息表单'},{value:'choice',label:'选择判断'},{value:'condition',label:'条件节点'},{value:'knowledge',label:'知识卡'},{value:'escalate',label:'转人工/转诊'},{value:'finish',label:'结束'}]
|
const nodeTypes = [{value:'message',label:'标准话术'},{value:'question',label:'单项提问'},{value:'form',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: '' })
|
interface ConditionRuleDraft { field:string; operator:string; value:string }
|
||||||
|
const newConditionRule = ():ConditionRuleDraft => ({ field:'', operator:'equals', value:'' })
|
||||||
|
const edgeDraft = reactive({ target_node_key: '', conditional: false, mode: 'all', rules: [newConditionRule()] as ConditionRuleDraft[] })
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
const data = await api.get<{ sop:SOP; version:SOPVersion; nodes:SOPNode[]; edges:SOPEdge[] }>(`/sops/${id}`)
|
const requestedVersion = Number(route.query.version || 0)
|
||||||
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 || ''
|
const data = await api.get<{ sop:SOP; version:SOPVersion; nodes:SOPNode[]; edges:SOPEdge[] }>(`/sops/${id}`, { params: requestedVersion ? { version: requestedVersion } : {} })
|
||||||
const [scenarioData, cardData] = await Promise.all([
|
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 || ''
|
||||||
|
const [scenarioData, cardData, versionData] = await Promise.all([
|
||||||
api.get<{ fields:ScenarioField[] }>(`/scenarios/${data.sop.scenario_id}`),
|
api.get<{ fields:ScenarioField[] }>(`/scenarios/${data.sop.scenario_id}`),
|
||||||
api.get<{ items:KnowledgeCard[] }>('/knowledge-cards'),
|
api.get<{ items:KnowledgeCard[] }>('/knowledge-cards'),
|
||||||
|
api.get<{ items:SOPVersion[] }>(`/sops/${id}/versions`),
|
||||||
])
|
])
|
||||||
fields.value=scenarioData.fields; knowledgeCards.value=cardData.items.filter(card=>card.scenario_id===data.sop.scenario_id)
|
fields.value=scenarioData.fields; knowledgeCards.value=(cardData.items||[]).filter(card=>card.scenario_id===data.sop.scenario_id); versions.value=versionData.items||[]
|
||||||
} catch(error) { message.error(apiMessage(error)) } finally { loading.value=false }
|
} catch(error) { message.error(apiMessage(error)) } finally { loading.value=false }
|
||||||
}
|
}
|
||||||
function addNode() {
|
function addNode() {
|
||||||
@@ -42,25 +50,44 @@ function removeNode(node:SOPNode) {
|
|||||||
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||''}})
|
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 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 outgoing(key:string){return edges.value.filter(e=>e.source_node_key===key).sort((a,b)=>a.priority-b.priority)}
|
||||||
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 isDefaultCondition(condition:Record<string,any>){return !condition||Object.keys(condition).length===0}
|
||||||
function removeEdge(edge:SOPEdge){edges.value=edges.value.filter(e=>e!==edge)}
|
function normalizeOutgoing(key:string){const related=edges.value.filter(edge=>edge.source_node_key===key).sort((a,b)=>Number(isDefaultCondition(a.condition))-Number(isDefaultCondition(b.condition)));related.forEach((edge,index)=>edge.priority=index)}
|
||||||
|
function ruleValue(rule:ConditionRuleDraft){if(rule.operator==='in')return rule.value.split(',').map(value=>parseValue(value.trim())).filter(value=>value!=='');if(['exists','not_exists'].includes(rule.operator))return undefined;return parseValue(rule.value)}
|
||||||
|
function addEdge(){
|
||||||
|
if(!selected.value||!edgeDraft.target_node_key)return
|
||||||
|
let condition:Record<string,any>={}
|
||||||
|
if(edgeDraft.conditional){
|
||||||
|
const rules=edgeDraft.rules.filter(rule=>rule.field).map(rule=>{const item:Record<string,any>={field:rule.field,operator:rule.operator};const value=ruleValue(rule);if(value!==undefined)item.value=value;return item})
|
||||||
|
if(!rules.length)return message.warning('请至少配置一条有效条件')
|
||||||
|
condition=rules.length===1?rules[0]:{[edgeDraft.mode]:rules}
|
||||||
|
}
|
||||||
|
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})
|
||||||
|
normalizeOutgoing(selected.value.node_key)
|
||||||
|
edgeDraft.target_node_key='';edgeDraft.conditional=false;edgeDraft.mode='all';edgeDraft.rules.splice(0,edgeDraft.rules.length,newConditionRule())
|
||||||
|
}
|
||||||
|
function removeEdge(edge:SOPEdge){edges.value=edges.value.filter(item=>item!==edge);normalizeOutgoing(edge.source_node_key)}
|
||||||
|
function addConditionRule(){edgeDraft.rules.push(newConditionRule())}
|
||||||
|
function removeConditionRule(index:number){if(edgeDraft.rules.length>1)edgeDraft.rules.splice(index,1)}
|
||||||
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 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>):string{if(!condition||Object.keys(condition).length===0)return '默认路径';if(Array.isArray(condition.any))return `任一:${condition.any.map(conditionText).join(';')}`;if(Array.isArray(condition.all))return `全部:${condition.all.map(conditionText).join(';')}`;if(!condition.field)return '未识别条件';const names:Record<string,string>={equals:'等于',not_equals:'不等于',contains:'包含',greater_than:'大于',less_than:'小于',exists:'已填写',not_exists:'未填写'};const fieldName=fields.value.find(field=>field.field_key===condition.field)?.field_name||condition.field;return `${fieldName} ${names[condition.operator]||condition.operator} ${condition.value ?? ''}`}
|
function conditionText(condition:Record<string,any>):string{if(!condition||Object.keys(condition).length===0)return '默认路径';if(Array.isArray(condition.any))return `任一:${condition.any.map(conditionText).join(';')}`;if(Array.isArray(condition.all))return `全部:${condition.all.map(conditionText).join(';')}`;if(!condition.field)return '未识别条件';const names:Record<string,string>={equals:'等于',not_equals:'不等于',contains:'包含',greater_than:'大于',less_than:'小于',in:'属于列表',exists:'已填写',not_exists:'未填写'};const fieldName=fields.value.find(field=>field.field_key===condition.field)?.field_name||condition.field;const value=Array.isArray(condition.value)?condition.value.join('、'):(condition.value??'');return `${fieldName} ${names[condition.operator]||condition.operator} ${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 save():Promise<boolean>{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();return true}catch(error){message.error(apiMessage(error));return false}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 validate(){try{const suffix=route.query.version?`?version=${encodeURIComponent(String(route.query.version))}`:'';const data=await api.post<{valid:boolean;problems:string[]}>(`/sops/${id}/validate${suffix}`);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 submitReview(){try{if(!(await save()))return;await api.post(`/sops/${id}/submit-review`);message.success('SOP 已提交审核');await load()}catch(error){message.error(apiMessage(error))}}
|
||||||
async function publish(){try{if(canEdit.value) await save();await api.post(`/sops/${id}/publish`);message.success('SOP 已发布');await load()}catch(error){message.error(apiMessage(error))}}
|
async function publish(){try{if(canEdit.value&&!(await save()))return;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 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))}}
|
async function createVersion(){try{await api.post(`/sops/${id}/versions`);await router.replace({query:{}});message.success('已创建新草稿版本');await load()}catch(error){message.error(apiMessage(error))}}
|
||||||
|
async function viewVersion(value:number){await router.replace({query:{version:String(value)}});historyOpen.value=false;await load()}
|
||||||
|
function rollbackVersion(item:SOPVersion){Modal.confirm({title:`回滚到 V${item.version}?`,content:'系统会复制该历史版本并生成一个新的已发布版本,当前已发布版本将保留为历史记录。',okText:'确认回滚',async onOk(){rollingBack.value=true;try{const data=await api.post<{version:number}>(`/sops/${id}/rollback`,{version:item.version});await router.replace({query:{}});historyOpen.value=false;message.success(`已回滚并发布为 V${data.version}`);await load()}catch(error){message.error(apiMessage(error))}finally{rollingBack.value=false}}})}
|
||||||
|
function versionStatus(value:string){return({draft:'草稿',reviewing:'审核中',published:'已发布',superseded:'历史版本',offline:'已下线'} as Record<string,string>)[value]||value}
|
||||||
onMounted(load)
|
onMounted(load)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="editor-page">
|
<div class="editor-page">
|
||||||
<header class="editor-header">
|
<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="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 }} · {{ versionStatus(version?.status || '') }}</span></div></div>
|
||||||
<div class="page-actions"><a-button @click="validate">校验流程</a-button><a-button v-if="canEdit" :loading="saving" @click="save"><SaveOutlined />保存</a-button><a-button v-if="canEdit&&auth.can('sop.submit_review')" @click="submitReview"><SendOutlined />提交审核</a-button><a-button v-if="auth.can('sop.publish')&&(reviewing||(editable&&auth.can('*')))" type="primary" @click="publish"><SendOutlined />发布</a-button><a-button v-if="version?.status === 'published'&&auth.can('sop.publish')" danger @click="offline">下线</a-button><a-button v-if="auth.can('sop.edit')&&!editable&&version?.status !== 'reviewing'" type="primary" @click="createVersion"><PlusOutlined />创建新版本</a-button></div>
|
<div class="page-actions"><a-button @click="historyOpen=true"><HistoryOutlined />版本历史</a-button><a-button @click="validate">校验流程</a-button><a-button v-if="canEdit" :loading="saving" @click="save"><SaveOutlined />保存</a-button><a-button v-if="canEdit&&auth.can('sop.submit_review')" @click="submitReview"><SendOutlined />提交审核</a-button><a-button v-if="auth.can('sop.publish')&&(reviewing||(editable&&auth.can('*')))" type="primary" @click="publish"><SendOutlined />发布</a-button><a-button v-if="version?.status === 'published'&&auth.can('sop.publish')" danger @click="offline">下线</a-button><a-button v-if="auth.can('sop.edit')&&isLatestVersion&&!hasPendingVersion&&!editable&&version?.status !== 'reviewing'" type="primary" @click="createVersion"><PlusOutlined />创建新版本</a-button></div>
|
||||||
</header>
|
</header>
|
||||||
<a-spin :spinning="loading">
|
<a-spin :spinning="loading">
|
||||||
<main class="editor-workbench">
|
<main class="editor-workbench">
|
||||||
@@ -80,17 +107,33 @@ onMounted(load)
|
|||||||
<a-form-item v-if="selected.type==='choice'" label="可选项(每行一个)"><a-textarea :value="(selected.config.options||[]).join('\n')" :disabled="!canEdit" :rows="4" @change="selected.config.options=($event.target as HTMLTextAreaElement).value.split('\n').filter(Boolean)" /></a-form-item>
|
<a-form-item v-if="selected.type==='choice'" label="可选项(每行一个)"><a-textarea :value="(selected.config.options||[]).join('\n')" :disabled="!canEdit" :rows="4" @change="selected.config.options=($event.target as HTMLTextAreaElement).value.split('\n').filter(Boolean)" /></a-form-item>
|
||||||
<a-form-item v-if="selected.type==='knowledge'" label="关联知识卡"><a-select v-model:value="selected.config.knowledge_card_id" :disabled="!canEdit" placeholder="选择已发布知识卡" :options="knowledgeCards.map(card=>({value:card.id,label:card.title}))" /></a-form-item>
|
<a-form-item v-if="selected.type==='knowledge'" label="关联知识卡"><a-select v-model:value="selected.config.knowledge_card_id" :disabled="!canEdit" placeholder="选择已发布知识卡" :options="knowledgeCards.map(card=>({value:card.id,label:card.title}))" /></a-form-item>
|
||||||
</a-form>
|
</a-form>
|
||||||
<div class="transition-head"><div><b>下一步路径</b><span>按优先级匹配,默认路径建议放最后</span></div></div>
|
<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="canEdit" type="text" danger aria-label="删除路径" @click="removeEdge(edge)"><DeleteOutlined /></a-button></div><div v-if="!outgoing(selectedKey).length" class="route-empty">还没有下一步路径</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="canEdit" 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="canEdit&&!['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>
|
<div v-if="canEdit&&!['finish','escalate'].includes(selected.type)" class="edge-builder">
|
||||||
|
<div class="edge-main"><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}))" /><span class="condition-toggle"><a-switch v-model:checked="edgeDraft.conditional" size="small" />使用条件</span><a-button type="primary" @click="addEdge"><PlusOutlined />添加路径</a-button></div>
|
||||||
|
<div v-if="edgeDraft.conditional" class="condition-builder">
|
||||||
|
<div class="condition-mode"><span>匹配方式</span><a-segmented v-model:value="edgeDraft.mode" :options="[{label:'全部满足',value:'all'},{label:'任一满足',value:'any'}]" /></div>
|
||||||
|
<div v-for="(rule,index) in edgeDraft.rules" :key="index" class="condition-rule"><a-select v-model:value="rule.field" show-search option-filter-prop="label" placeholder="场景字段" :options="fields.map(field=>({value:field.field_key,label:field.field_name}))" /><a-select v-model:value="rule.operator" :options="[{value:'equals',label:'等于'},{value:'not_equals',label:'不等于'},{value:'contains',label:'包含'},{value:'greater_than',label:'大于'},{value:'less_than',label:'小于'},{value:'in',label:'属于列表'},{value:'exists',label:'已填写'},{value:'not_exists',label:'未填写'}]" /><a-input v-model:value="rule.value" :disabled="['exists','not_exists'].includes(rule.operator)" :placeholder="rule.operator==='in'?'多个值用逗号分隔':'条件值'" /><a-button type="text" danger aria-label="删除条件" :disabled="edgeDraft.rules.length===1" @click="removeConditionRule(index)"><DeleteOutlined /></a-button></div>
|
||||||
|
<a-button type="dashed" size="small" @click="addConditionRule"><PlusOutlined />增加条件</a-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<a-empty v-else description="请选择一个节点" class="surface editor-empty" />
|
<a-empty v-else description="请选择一个节点" class="surface editor-empty" />
|
||||||
</main>
|
</main>
|
||||||
</a-spin>
|
</a-spin>
|
||||||
|
<a-drawer v-model:open="historyOpen" title="SOP 版本历史" width="580">
|
||||||
|
<div class="version-list">
|
||||||
|
<section v-for="item in versions" :key="item.id" class="version-row" :class="{current:item.version===version?.version}">
|
||||||
|
<div class="version-number">V{{ item.version }}</div>
|
||||||
|
<div class="version-copy"><div><a-tag :color="item.status==='published'?'green':item.status==='reviewing'?'orange':'default'">{{ versionStatus(item.status) }}</a-tag><span>{{ item.creator_name || '未知创建人' }}</span></div><time>{{ new Date(item.published_at || item.created_at).toLocaleString('zh-CN') }}</time></div>
|
||||||
|
<div class="version-actions"><a-button size="small" @click="viewVersion(item.version)">查看</a-button><a-tooltip v-if="auth.can('sop.publish')&&['superseded','offline'].includes(item.status)&&!hasPendingVersion" title="复制该版本并作为新版本发布"><a-button size="small" :loading="rollingBack" @click="rollbackVersion(item)"><RollbackOutlined />回滚</a-button></a-tooltip></div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</a-drawer>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<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}
|
.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}.page-actions{display:flex;flex-wrap:wrap;justify-content:flex-end;gap:8px}.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{padding-top:16px}.edge-main{display:grid;grid-template-columns:minmax(220px,1fr) auto auto;align-items:center;gap:10px}.condition-toggle{display:flex;align-items:center;gap:7px;color:var(--muted);font-size:12px}.condition-builder{margin-top:12px;padding:14px;background:#f7f9f8;border:1px solid var(--line);border-radius:4px}.condition-mode{display:flex;align-items:center;justify-content:space-between;margin-bottom:10px;color:var(--muted);font-size:12px}.condition-rule{display:grid;grid-template-columns:1.2fr 130px 1fr 34px;gap:8px;margin-top:8px}.editor-empty{min-height:400px;display:grid;place-items:center}.version-row{display:grid;grid-template-columns:54px 1fr auto;align-items:center;gap:12px;padding:16px 8px;border-bottom:1px solid var(--line)}.version-row.current{background:#f1f7f4}.version-number{font-weight:750;color:#245f4d}.version-copy>div{display:flex;align-items:center;gap:6px}.version-copy time{display:block;margin-top:5px;color:var(--muted);font-size:11px}.version-actions{display:flex;gap:6px}
|
||||||
@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}}
|
@media(max-width:1050px){.editor-workbench{grid-template-columns:230px minmax(0,1fr)}.condition-rule{grid-template-columns:1fr 120px 1fr 34px}}@media(max-width:760px){.editor-header{top:62px;align-items:flex-start;flex-direction:column;padding:12px}.page-actions{justify-content:flex-start}.editor-workbench{grid-template-columns:1fr;padding:10px}.node-rail{position:static}.property-grid{grid-template-columns:1fr}.node-editor{padding:16px}.transition-head{margin:8px -16px 0;padding:16px}.edge-main,.condition-rule{grid-template-columns:1fr}.condition-rule .ant-btn{justify-self:end}.version-row{grid-template-columns:46px 1fr}.version-actions{grid-column:2}}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, reactive, ref } from 'vue'
|
import { computed, onMounted, reactive, ref } from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import { ArrowLeftOutlined, BranchesOutlined, DeleteOutlined, PlusOutlined } from '@ant-design/icons-vue'
|
import { ArrowLeftOutlined, BranchesOutlined, DeleteOutlined, EditOutlined, PlusOutlined } from '@ant-design/icons-vue'
|
||||||
import { message, Modal } from 'ant-design-vue'
|
import { message, Modal } from 'ant-design-vue'
|
||||||
import { api, apiMessage } from '@/api/client'
|
import { api, apiMessage } from '@/api/client'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
@@ -12,40 +12,154 @@ const router = useRouter()
|
|||||||
const auth = useAuthStore()
|
const auth = useAuthStore()
|
||||||
const scenarioId = Number(route.params.id)
|
const scenarioId = Number(route.params.id)
|
||||||
const loading = ref(true)
|
const loading = ref(true)
|
||||||
|
const saving = ref(false)
|
||||||
const scenario = ref<Scenario | null>(null)
|
const scenario = ref<Scenario | null>(null)
|
||||||
const fields = ref<ScenarioField[]>([])
|
const fields = ref<ScenarioField[]>([])
|
||||||
const sops = ref<SOP[]>([])
|
const sops = ref<SOP[]>([])
|
||||||
|
const scenarioOpen = ref(false)
|
||||||
const fieldOpen = ref(false)
|
const fieldOpen = ref(false)
|
||||||
const sopOpen = ref(false)
|
const sopOpen = ref(false)
|
||||||
const fieldForm = reactive({ field_key: '', field_name: '', field_type: 'text', required: false, options_text: '', sort_order: 0 })
|
const editingFieldId = ref<number | null>(null)
|
||||||
|
const scenarioForm = reactive({ name: '', industry: '', role_name: '', goal: '', trigger_text: '', visibility: 'tenant' })
|
||||||
|
const fieldForm = reactive({ field_key: '', field_name: '', field_type: 'text', required: false, options_text: '', min: undefined as number | undefined, max: undefined as number | undefined, min_length: undefined as number | undefined, max_length: undefined as number | undefined })
|
||||||
const sopForm = reactive({ name: '', description: '' })
|
const sopForm = reactive({ name: '', description: '' })
|
||||||
const statusColor = computed(() => scenario.value?.status === 'active' ? 'green' : 'default')
|
const statusColor = computed(() => scenario.value?.status === 'active' ? 'green' : 'default')
|
||||||
|
const fieldModalTitle = computed(() => editingFieldId.value ? '编辑场景字段' : '添加场景字段')
|
||||||
|
const fieldTypeOptions = [
|
||||||
|
{ value: 'text', label: '单行文本' }, { value: 'textarea', label: '多行文本' }, { value: 'number', label: '数字' },
|
||||||
|
{ value: 'boolean', label: '是/否' }, { value: 'select', label: '单选' }, { value: 'multiselect', label: '多选' }, { value: 'date', label: '日期' },
|
||||||
|
]
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
const data = await api.get<{ scenario: Scenario; fields: ScenarioField[]; sops: SOP[] }>(`/scenarios/${scenarioId}`)
|
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
|
scenario.value = data.scenario
|
||||||
} catch (error) { message.error(apiMessage(error)) } finally { loading.value = false }
|
fields.value = data.fields || []
|
||||||
|
sops.value = data.sops || []
|
||||||
|
} catch (error) {
|
||||||
|
message.error(apiMessage(error))
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
}
|
}
|
||||||
async function createField() {
|
}
|
||||||
|
|
||||||
|
function openScenarioEditor() {
|
||||||
|
if (!scenario.value) return
|
||||||
|
Object.assign(scenarioForm, {
|
||||||
|
name: scenario.value.name, industry: scenario.value.industry, role_name: scenario.value.role_name,
|
||||||
|
goal: scenario.value.goal, trigger_text: scenario.value.trigger_text, visibility: scenario.value.visibility,
|
||||||
|
})
|
||||||
|
scenarioOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateScenario() {
|
||||||
|
saving.value = true
|
||||||
try {
|
try {
|
||||||
const options = fieldForm.options_text.split('\n').map(v => v.trim()).filter(Boolean)
|
await api.put(`/scenarios/${scenarioId}`, scenarioForm)
|
||||||
await api.post(`/scenarios/${scenarioId}/fields`, { ...fieldForm, options, validation: {}, sort_order: fields.value.length })
|
message.success('场景资料已更新')
|
||||||
message.success('字段已添加'); fieldOpen.value = false
|
scenarioOpen.value = false
|
||||||
Object.assign(fieldForm, { field_key: '', field_name: '', field_type: 'text', required: false, options_text: '', sort_order: 0 }); await load()
|
await load()
|
||||||
|
} catch (error) {
|
||||||
|
message.error(apiMessage(error))
|
||||||
|
} finally {
|
||||||
|
saving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function archiveScenario() {
|
||||||
|
Modal.confirm({
|
||||||
|
title: `归档“${scenario.value?.name}”?`,
|
||||||
|
content: '归档后场景不再出现在列表中。存在已发布或审核中 SOP 时,需要先处理对应版本。',
|
||||||
|
okText: '归档场景', okType: 'danger', cancelText: '取消',
|
||||||
|
async onOk() {
|
||||||
|
try {
|
||||||
|
await api.delete(`/scenarios/${scenarioId}`)
|
||||||
|
message.success('场景已归档')
|
||||||
|
router.replace('/scenarios')
|
||||||
} catch (error) { message.error(apiMessage(error)) }
|
} catch (error) { message.error(apiMessage(error)) }
|
||||||
|
},
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resetFieldForm() {
|
||||||
|
editingFieldId.value = null
|
||||||
|
Object.assign(fieldForm, { field_key: '', field_name: '', field_type: 'text', required: false, options_text: '', min: undefined, max: undefined, min_length: undefined, max_length: undefined })
|
||||||
|
}
|
||||||
|
|
||||||
|
function openNewField() {
|
||||||
|
resetFieldForm()
|
||||||
|
fieldOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function openFieldEditor(field: ScenarioField) {
|
||||||
|
const validation = field.validation || {}
|
||||||
|
editingFieldId.value = field.id
|
||||||
|
Object.assign(fieldForm, {
|
||||||
|
field_key: field.field_key, field_name: field.field_name, field_type: field.field_type, required: field.required,
|
||||||
|
options_text: Array.isArray(field.options) ? field.options.join('\n') : '',
|
||||||
|
min: typeof validation.min === 'number' ? validation.min : undefined,
|
||||||
|
max: typeof validation.max === 'number' ? validation.max : undefined,
|
||||||
|
min_length: typeof validation.min_length === 'number' ? validation.min_length : undefined,
|
||||||
|
max_length: typeof validation.max_length === 'number' ? validation.max_length : undefined,
|
||||||
|
})
|
||||||
|
fieldOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveField() {
|
||||||
|
const options = fieldForm.options_text.split('\n').map(value => value.trim()).filter(Boolean)
|
||||||
|
const validation: Record<string, number> = {}
|
||||||
|
if (fieldForm.field_type === 'number') {
|
||||||
|
if (fieldForm.min !== undefined) validation.min = fieldForm.min
|
||||||
|
if (fieldForm.max !== undefined) validation.max = fieldForm.max
|
||||||
|
}
|
||||||
|
if (['text', 'textarea'].includes(fieldForm.field_type)) {
|
||||||
|
if (fieldForm.min_length !== undefined) validation.min_length = fieldForm.min_length
|
||||||
|
if (fieldForm.max_length !== undefined) validation.max_length = fieldForm.max_length
|
||||||
|
}
|
||||||
|
const payload = { ...fieldForm, options, validation, sort_order: editingFieldId.value ? fields.value.find(field => field.id === editingFieldId.value)?.sort_order || 0 : fields.value.length }
|
||||||
|
saving.value = true
|
||||||
|
try {
|
||||||
|
if (editingFieldId.value) await api.put(`/scenario-fields/${editingFieldId.value}`, payload)
|
||||||
|
else await api.post(`/scenarios/${scenarioId}/fields`, payload)
|
||||||
|
message.success(editingFieldId.value ? '字段已更新' : '字段已添加')
|
||||||
|
fieldOpen.value = false
|
||||||
|
resetFieldForm()
|
||||||
|
await load()
|
||||||
|
} catch (error) {
|
||||||
|
message.error(apiMessage(error))
|
||||||
|
} finally {
|
||||||
|
saving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function deleteField(field: ScenarioField) {
|
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() } })
|
Modal.confirm({
|
||||||
|
title: `删除字段“${field.field_name}”?`, content: '已发布或历史 SOP 版本引用的字段不能删除。', okText: '删除字段', okType: 'danger',
|
||||||
|
async onOk() {
|
||||||
|
try { await api.delete(`/scenario-fields/${field.id}`); message.success('字段已删除'); await load() }
|
||||||
|
catch (error) { message.error(apiMessage(error)) }
|
||||||
|
},
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
async function createSOP() {
|
async function createSOP() {
|
||||||
|
saving.value = true
|
||||||
try {
|
try {
|
||||||
const data = await api.post<{ sop: SOP }>(`/scenarios/${scenarioId}/sops`, sopForm)
|
const data = await api.post<{ sop: SOP }>(`/scenarios/${scenarioId}/sops`, sopForm)
|
||||||
message.success('SOP 草稿已创建'); sopOpen.value = false; router.push(`/sops/${data.sop.id}`)
|
message.success('SOP 草稿已创建')
|
||||||
} catch (error) { message.error(apiMessage(error)) }
|
sopOpen.value = false
|
||||||
|
router.push(`/sops/${data.sop.id}`)
|
||||||
|
} catch (error) {
|
||||||
|
message.error(apiMessage(error))
|
||||||
|
} finally {
|
||||||
|
saving.value = false
|
||||||
}
|
}
|
||||||
function statusText(value: string) { return ({ draft: '草稿', published: '已发布' } as Record<string,string>)[value] || value }
|
}
|
||||||
|
|
||||||
|
function statusText(value: string) { return ({ draft: '草稿', reviewing: '审核中', published: '已发布', offline: '已下线' } as Record<string, string>)[value] || value }
|
||||||
|
function fieldTypeText(value: string) { return fieldTypeOptions.find(item => item.value === value)?.label || value }
|
||||||
|
function visibilityText(value: string) { return ({ tenant: '全企业', team: '团队', private: '仅自己' } as Record<string, string>)[value] || value }
|
||||||
onMounted(load)
|
onMounted(load)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -54,16 +168,28 @@ onMounted(load)
|
|||||||
<a-button type="link" class="back-link" @click="router.push('/scenarios')"><ArrowLeftOutlined />返回场景列表</a-button>
|
<a-button type="link" class="back-link" @click="router.push('/scenarios')"><ArrowLeftOutlined />返回场景列表</a-button>
|
||||||
<a-skeleton :loading="loading" active>
|
<a-skeleton :loading="loading" active>
|
||||||
<div v-if="scenario" class="detail-heading">
|
<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-summary">
|
||||||
<div class="scenario-code">SCN-{{ String(scenario.id).padStart(4, '0') }}</div>
|
<div class="heading-tags"><a-tag :color="statusColor">{{ scenario.status === 'active' ? '使用中' : '草稿' }}</a-tag><span>{{ scenario.industry }}</span><span>{{ scenario.role_name }}</span><span>{{ visibilityText(scenario.visibility) }}</span></div>
|
||||||
|
<h1>{{ scenario.name }}</h1><p>{{ scenario.goal }}</p>
|
||||||
|
<div class="trigger-copy"><span>触发条件</span>{{ scenario.trigger_text }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="heading-actions">
|
||||||
|
<span class="scenario-code">SCN-{{ String(scenario.id).padStart(4, '0') }}</span>
|
||||||
|
<div v-if="auth.can('scenario.edit')"><a-button ghost @click="openScenarioEditor"><EditOutlined />编辑</a-button><a-button ghost danger @click="archiveScenario"><DeleteOutlined />归档</a-button></div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<a-tabs default-active-key="fields" class="detail-tabs">
|
<a-tabs default-active-key="fields" class="detail-tabs">
|
||||||
<a-tab-pane key="fields" tab="场景字段">
|
<a-tab-pane key="fields" tab="场景字段">
|
||||||
<section class="surface">
|
<section class="surface">
|
||||||
<div class="toolbar"><div><b>采集字段</b><span class="toolbar-note">执行过程中收集的信息</span></div><a-button v-if="auth.can('scenario.edit')" type="primary" @click="fieldOpen = true"><PlusOutlined />添加字段</a-button></div>
|
<div class="toolbar"><div><b>采集字段</b><span class="toolbar-note">执行过程中收集的信息及校验规则</span></div><a-button v-if="auth.can('scenario.edit')" type="primary" @click="openNewField"><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}]">
|
<a-table :data-source="fields" row-key="id" :pagination="false" :scroll="{x:760}" :columns="[{title:'字段名称',dataIndex:'field_name'},{title:'字段标识',dataIndex:'field_key'},{title:'类型',dataIndex:'field_type',width:130},{title:'必填',dataIndex:'required',width:90},{title:'',key:'action',width:100}]">
|
||||||
<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 v-if="auth.can('scenario.edit')" type="text" danger aria-label="删除字段" @click="deleteField(record)"><DeleteOutlined /></a-button></template></template>
|
<template #bodyCell="{ column, record }">
|
||||||
|
<template v-if="column.dataIndex === 'field_key'"><code>{{ record.field_key }}</code></template>
|
||||||
|
<template v-else-if="column.dataIndex === 'field_type'">{{ fieldTypeText(record.field_type) }}</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'"><div v-if="auth.can('scenario.edit')" class="row-actions"><a-button type="text" aria-label="编辑字段" @click="openFieldEditor(record)"><EditOutlined /></a-button><a-button type="text" danger aria-label="删除字段" @click="deleteField(record)"><DeleteOutlined /></a-button></div></template>
|
||||||
|
</template>
|
||||||
<template #emptyText><div class="empty-copy">还没有字段。先添加执行过程中需要收集的客户信息。</div></template>
|
<template #emptyText><div class="empty-copy">还没有字段。先添加执行过程中需要收集的客户信息。</div></template>
|
||||||
</a-table>
|
</a-table>
|
||||||
</section>
|
</section>
|
||||||
@@ -80,22 +206,25 @@ onMounted(load)
|
|||||||
</a-tabs>
|
</a-tabs>
|
||||||
</a-skeleton>
|
</a-skeleton>
|
||||||
|
|
||||||
<a-modal v-if="auth.can('scenario.edit')" v-model:open="fieldOpen" title="添加场景字段" ok-text="添加字段" @ok="createField">
|
<a-modal v-if="auth.can('scenario.edit')" v-model:open="scenarioOpen" title="编辑场景资料" ok-text="保存修改" :confirm-loading="saving" @ok="updateScenario">
|
||||||
|
<a-form layout="vertical" :model="scenarioForm"><a-form-item label="场景名称"><a-input v-model:value="scenarioForm.name" /></a-form-item><div class="field-grid"><a-form-item label="所属行业"><a-input v-model:value="scenarioForm.industry" /></a-form-item><a-form-item label="适用角色"><a-input v-model:value="scenarioForm.role_name" /></a-form-item></div><a-form-item label="执行目标"><a-textarea v-model:value="scenarioForm.goal" :rows="3" /></a-form-item><a-form-item label="触发条件"><a-textarea v-model:value="scenarioForm.trigger_text" :rows="3" /></a-form-item><a-form-item label="可见范围"><a-radio-group v-model:value="scenarioForm.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-form>
|
||||||
|
</a-modal>
|
||||||
|
<a-modal v-if="auth.can('scenario.edit')" v-model:open="fieldOpen" :title="fieldModalTitle" :ok-text="editingFieldId ? '保存字段' : '添加字段'" :confirm-loading="saving" @ok="saveField" @cancel="resetFieldForm">
|
||||||
<a-form layout="vertical" :model="fieldForm">
|
<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>
|
<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 label="字段类型"><a-select v-model:value="fieldForm.field_type" :options="fieldTypeOptions" /></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-form-item v-if="['select','multiselect'].includes(fieldForm.field_type)" label="选项(每行一个)"><a-textarea v-model:value="fieldForm.options_text" :rows="4" /></a-form-item>
|
||||||
|
<div v-if="fieldForm.field_type === 'number'" class="field-grid"><a-form-item label="最小值"><a-input-number v-model:value="fieldForm.min" style="width:100%" /></a-form-item><a-form-item label="最大值"><a-input-number v-model:value="fieldForm.max" style="width:100%" /></a-form-item></div>
|
||||||
|
<div v-if="['text','textarea'].includes(fieldForm.field_type)" class="field-grid"><a-form-item label="最少字符"><a-input-number v-model:value="fieldForm.min_length" :min="0" style="width:100%" /></a-form-item><a-form-item label="最多字符"><a-input-number v-model:value="fieldForm.max_length" :min="1" style="width:100%" /></a-form-item></div>
|
||||||
<a-checkbox v-model:checked="fieldForm.required">执行时必须填写</a-checkbox>
|
<a-checkbox v-model:checked="fieldForm.required">执行时必须填写</a-checkbox>
|
||||||
</a-form>
|
</a-form>
|
||||||
</a-modal>
|
</a-modal>
|
||||||
<a-modal v-if="auth.can('sop.edit')" v-model:open="sopOpen" title="创建 SOP 草稿" ok-text="创建并编排" @ok="createSOP">
|
<a-modal v-if="auth.can('sop.edit')" v-model:open="sopOpen" title="创建 SOP 草稿" :footer="null">
|
||||||
<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-form layout="vertical" :model="sopForm" @finish="createSOP"><a-form-item label="SOP 名称" name="name" :rules="[{required:true,message:'请输入 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-button type="primary" html-type="submit" block :loading="saving">创建并编排</a-button></a-form>
|
||||||
</a-modal>
|
</a-modal>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<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; }
|
.back-link{margin:-4px 0 12px -14px;color:var(--muted)}.detail-heading{min-height:180px;display:flex;align-items:flex-end;justify-content:space-between;gap:24px;padding:28px;color:white;background:#202825;border-radius:6px}.scenario-summary{min-width:0}.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:#c3cec9}.heading-tags{display:flex;align-items:center;gap:10px;color:#a8b6b0;font-size:12px}.trigger-copy{max-width:800px;margin-top:14px;color:#aab8b2;font-size:12px}.trigger-copy span{margin-right:9px;color:#68c6a6;font-weight:700}.heading-actions{align-self:stretch;display:flex;flex-direction:column;align-items:flex-end;justify-content:space-between}.heading-actions>div{display:flex;gap:8px}.scenario-code{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}.row-actions{display:flex}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;padding:20px}.heading-actions{width:100%;align-items:flex-start;gap:14px}.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}.toolbar-note{display:none}}
|
||||||
.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>
|
</style>
|
||||||
|
|||||||
@@ -59,13 +59,13 @@ onMounted(load)
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<a-drawer v-if="auth.can('scenario.edit')" v-model:open="open" title="创建业务场景" width="520" :destroy-on-close="true">
|
<a-drawer v-if="auth.can('scenario.edit')" v-model:open="open" title="创建业务场景" width="520" :destroy-on-close="true">
|
||||||
<a-form layout="vertical" :model="form">
|
<a-form layout="vertical" :model="form" @finish="create">
|
||||||
<a-form-item label="场景名称" name="name" :rules="[{ required: true, message: '请输入场景名称' }]"><a-input v-model:value="form.name" placeholder="例如:宠物医生问诊问药" /></a-form-item>
|
<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>
|
<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="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="触发条件" 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-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-button type="primary" html-type="submit" block>创建并继续配置</a-button>
|
||||||
</a-form>
|
</a-form>
|
||||||
</a-drawer>
|
</a-drawer>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user