feat: complete SOP version management

This commit is contained in:
Eric 1549169735@qq.com
2026-08-08 23:29:56 +08:00
parent 4b240499c2
commit e32025d2ca
40 changed files with 966 additions and 159 deletions

View File

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

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

View File

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

View File

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

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

View File

@@ -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,7 +35,14 @@ 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()))
} }
log.Info("http request", fields...) 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...)
}
} }
} }

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

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

View File

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

View File

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

View File

@@ -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{}

View File

@@ -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: "没有明确的转人工或转诊路径"},

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

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

View File

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

File diff suppressed because one or more lines are too long

View File

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

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

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

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

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
.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}}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

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

View 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

View File

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

View File

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

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

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

View File

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

View File

@@ -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 auth=useAuthStore() const loading = ref(false)
const form=reactive({scenario_id:undefined as number|undefined,title:'',content:'',forbidden:'',risk_note:''}) const saving = ref(false)
const scenarioOptions=computed(()=>scenarios.value.map(s=>({value:s.id,label:s.name}))) const open = ref(false)
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 historyOpen = ref(false)
async function create(){try{await api.post('/knowledge-cards',{scenario_id:form.scenario_id,title:form.title,content:{standard_copy:form.content,forbidden_copy:form.forbidden,risk_note:form.risk_note}});message.success('知识卡已发布');open.value=false;Object.assign(form,{scenario_id:undefined,title:'',content:'',forbidden:'',risk_note:''});await load()}catch(error){message.error(apiMessage(error))}} const editingId = ref<number | null>(null)
function remove(item:KnowledgeCard){Modal.confirm({title:`归档“${item.title}”?`,okType:'danger',async onOk(){await api.delete(`/knowledge-cards/${item.id}`);await load()}})} const historyCard = ref<KnowledgeCard | null>(null)
function contentOf(item:KnowledgeCard,key:string){return item.content?.[key]||''} const items = ref<KnowledgeCard[]>([])
const versions = ref<KnowledgeCardVersion[]>([])
const scenarios = ref<Scenario[]>([])
const auth = useAuthStore()
const form = reactive({ scenario_id: undefined as number | undefined, title: '', content: '', forbidden: '', risk_note: '' })
const scenarioOptions = computed(() => scenarios.value.map(item => ({ value: item.id, label: item.name })))
const drawerTitle = computed(() => editingId.value ? '编辑知识卡' : '新建知识卡')
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
}
}
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>

View File

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

View File

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

View File

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