feat: harden pet consultation SOP execution
This commit is contained in:
@@ -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 {
|
||||||
@@ -108,6 +109,7 @@ func (h *Handler) Answer(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
var input struct {
|
var input struct {
|
||||||
|
NodeKey string `json:"node_key"`
|
||||||
Answers map[string]interface{} `json:"answers"`
|
Answers map[string]interface{} `json:"answers"`
|
||||||
}
|
}
|
||||||
if err := c.ShouldBindJSON(&input); err != nil {
|
if err := c.ShouldBindJSON(&input); err != nil {
|
||||||
@@ -116,12 +118,26 @@ func (h *Handler) Answer(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
var updated model.SOPRun
|
var updated model.SOPRun
|
||||||
err := h.db.Transaction(func(tx *gorm.DB) error {
|
err := h.db.Transaction(func(tx *gorm.DB) error {
|
||||||
if err := tx.Clauses().Where("id = ? AND tenant_id = ?", id, p.TenantID).First(&updated).Error; err != nil {
|
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("id = ? AND tenant_id = ?", id, p.TenantID).First(&updated).Error; err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if updated.Status != "running" {
|
if updated.Status != "running" {
|
||||||
return errors.New("run is not active")
|
return errors.New("run is not active")
|
||||||
}
|
}
|
||||||
|
if input.NodeKey != "" && input.NodeKey != updated.CurrentNodeKey {
|
||||||
|
return errors.New("当前步骤已经变化,请刷新后重试")
|
||||||
|
}
|
||||||
|
var currentNode model.SOPNode
|
||||||
|
if err := tx.Where("sop_version_id = ? AND node_key = ? AND tenant_id = ?", updated.SOPVersionID, updated.CurrentNodeKey, p.TenantID).First(¤tNode).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var fields []model.ScenarioField
|
||||||
|
if err := tx.Table("scenario_fields sf").Joins("JOIN sops s ON s.scenario_id = sf.scenario_id").Where("s.id = ? AND sf.tenant_id = ?", updated.SOPID, p.TenantID).Order("sf.sort_order, sf.id").Find(&fields).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := validateNodeAnswers(currentNode, fields, input.Answers); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
answers := map[string]interface{}{}
|
answers := map[string]interface{}{}
|
||||||
if len(updated.Answers) > 0 {
|
if len(updated.Answers) > 0 {
|
||||||
_ = json.Unmarshal(updated.Answers, &answers)
|
_ = json.Unmarshal(updated.Answers, &answers)
|
||||||
|
|||||||
174
internal/run/validation.go
Normal file
174
internal/run/validation.go
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
package run
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.iwork-ai.com/xdc/iqudo-top1/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
type answerNodeConfig struct {
|
||||||
|
FieldKey string `json:"field_key"`
|
||||||
|
FieldKeys []string `json:"field_keys"`
|
||||||
|
Required bool `json:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateNodeAnswers(node model.SOPNode, fields []model.ScenarioField, answers map[string]interface{}) error {
|
||||||
|
var config answerNodeConfig
|
||||||
|
if len(node.Config) > 0 {
|
||||||
|
if err := json.Unmarshal(node.Config, &config); err != nil {
|
||||||
|
return fmt.Errorf("当前节点配置不正确")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fieldMap := make(map[string]model.ScenarioField, len(fields))
|
||||||
|
for _, field := range fields {
|
||||||
|
fieldMap[field.FieldKey] = field
|
||||||
|
}
|
||||||
|
expected := map[string]bool{}
|
||||||
|
switch node.Type {
|
||||||
|
case "question", "choice":
|
||||||
|
if config.FieldKey == "" {
|
||||||
|
return fmt.Errorf("当前节点没有配置采集字段")
|
||||||
|
}
|
||||||
|
expected[config.FieldKey] = config.Required
|
||||||
|
case "form":
|
||||||
|
if len(config.FieldKeys) == 0 {
|
||||||
|
return fmt.Errorf("当前表单没有配置采集字段")
|
||||||
|
}
|
||||||
|
for _, key := range config.FieldKeys {
|
||||||
|
expected[key] = false
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
if len(answers) > 0 {
|
||||||
|
return fmt.Errorf("当前节点不接受字段回答")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
for key := range answers {
|
||||||
|
if _, ok := expected[key]; !ok {
|
||||||
|
return fmt.Errorf("字段 %s 不属于当前节点", key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for key, nodeRequired := range expected {
|
||||||
|
field, exists := fieldMap[key]
|
||||||
|
if !exists {
|
||||||
|
return fmt.Errorf("字段 %s 不存在", key)
|
||||||
|
}
|
||||||
|
value, provided := answers[key]
|
||||||
|
required := field.Required || nodeRequired
|
||||||
|
if !provided || isEmptyValue(value) {
|
||||||
|
if required {
|
||||||
|
return fmt.Errorf("请填写%s", field.FieldName)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := validateFieldValue(field, value); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateFieldValue(field model.ScenarioField, value interface{}) error {
|
||||||
|
switch field.FieldType {
|
||||||
|
case "text", "textarea":
|
||||||
|
text, ok := value.(string)
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("%s必须是文本", field.FieldName)
|
||||||
|
}
|
||||||
|
var rules struct {
|
||||||
|
MinLength int `json:"min_length"`
|
||||||
|
MaxLength int `json:"max_length"`
|
||||||
|
}
|
||||||
|
_ = json.Unmarshal(field.Validation, &rules)
|
||||||
|
length := len([]rune(text))
|
||||||
|
if rules.MinLength > 0 && length < rules.MinLength {
|
||||||
|
return fmt.Errorf("%s不能少于%d个字符", field.FieldName, rules.MinLength)
|
||||||
|
}
|
||||||
|
if rules.MaxLength > 0 && length > rules.MaxLength {
|
||||||
|
return fmt.Errorf("%s不能超过%d个字符", field.FieldName, rules.MaxLength)
|
||||||
|
}
|
||||||
|
case "number":
|
||||||
|
number, ok := toFloat(value)
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("%s必须是数字", field.FieldName)
|
||||||
|
}
|
||||||
|
var rules struct {
|
||||||
|
Min *float64 `json:"min"`
|
||||||
|
Max *float64 `json:"max"`
|
||||||
|
}
|
||||||
|
_ = json.Unmarshal(field.Validation, &rules)
|
||||||
|
if rules.Min != nil && number < *rules.Min {
|
||||||
|
return fmt.Errorf("%s不能小于%v", field.FieldName, *rules.Min)
|
||||||
|
}
|
||||||
|
if rules.Max != nil && number > *rules.Max {
|
||||||
|
return fmt.Errorf("%s不能大于%v", field.FieldName, *rules.Max)
|
||||||
|
}
|
||||||
|
case "boolean":
|
||||||
|
if _, ok := value.(bool); !ok {
|
||||||
|
return fmt.Errorf("%s必须选择是或否", field.FieldName)
|
||||||
|
}
|
||||||
|
case "select":
|
||||||
|
selected, ok := value.(string)
|
||||||
|
if !ok || !optionAllowed(field.Options, selected) {
|
||||||
|
return fmt.Errorf("%s的选项不正确", field.FieldName)
|
||||||
|
}
|
||||||
|
case "multiselect":
|
||||||
|
values, ok := value.([]interface{})
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("%s必须是多选值", field.FieldName)
|
||||||
|
}
|
||||||
|
for _, item := range values {
|
||||||
|
selected, ok := item.(string)
|
||||||
|
if !ok || !optionAllowed(field.Options, selected) {
|
||||||
|
return fmt.Errorf("%s包含不正确的选项", field.FieldName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case "date":
|
||||||
|
text, ok := value.(string)
|
||||||
|
if !ok || !validDate(text) {
|
||||||
|
return fmt.Errorf("%s的日期格式不正确", field.FieldName)
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("%s的字段类型不支持", field.FieldName)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func optionAllowed(raw []byte, selected string) bool {
|
||||||
|
var options []string
|
||||||
|
if err := json.Unmarshal(raw, &options); err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, option := range options {
|
||||||
|
if option == selected {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func validDate(value string) bool {
|
||||||
|
for _, layout := range []string{time.RFC3339, "2006-01-02"} {
|
||||||
|
if _, err := time.Parse(layout, value); err == nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func isEmptyValue(value interface{}) bool {
|
||||||
|
if value == nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if text, ok := value.(string); ok {
|
||||||
|
return strings.TrimSpace(text) == ""
|
||||||
|
}
|
||||||
|
if values, ok := value.([]interface{}); ok {
|
||||||
|
return len(values) == 0
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
48
internal/run/validation_test.go
Normal file
48
internal/run/validation_test.go
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
package run
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.iwork-ai.com/xdc/iqudo-top1/internal/model"
|
||||||
|
"gorm.io/datatypes"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestValidateNodeAnswers(t *testing.T) {
|
||||||
|
fields := []model.ScenarioField{
|
||||||
|
{FieldKey: "pet_name", FieldName: "宠物名称", FieldType: "text", Required: true, Validation: datatypes.JSON([]byte(`{"max_length":20}`))},
|
||||||
|
{FieldKey: "pet_weight", FieldName: "体重", FieldType: "number", Validation: datatypes.JSON([]byte(`{"min":0.01,"max":200}`))},
|
||||||
|
{FieldKey: "pet_type", FieldName: "宠物种类", FieldType: "select", Options: datatypes.JSON([]byte(`["犬","猫"]`))},
|
||||||
|
}
|
||||||
|
node := model.SOPNode{Type: "form", Config: datatypes.JSON([]byte(`{"field_keys":["pet_name","pet_weight","pet_type"]}`))}
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
answers map[string]interface{}
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{name: "valid", answers: map[string]interface{}{"pet_name": "豆包", "pet_weight": 5.2, "pet_type": "犬"}},
|
||||||
|
{name: "missing required", answers: map[string]interface{}{"pet_weight": 5.2}, want: "请填写宠物名称"},
|
||||||
|
{name: "unknown field", answers: map[string]interface{}{"pet_name": "豆包", "owner_phone": "123"}, want: "不属于当前节点"},
|
||||||
|
{name: "invalid number", answers: map[string]interface{}{"pet_name": "豆包", "pet_weight": 0.0}, want: "不能小于"},
|
||||||
|
{name: "invalid option", answers: map[string]interface{}{"pet_name": "豆包", "pet_type": "兔"}, want: "选项不正确"},
|
||||||
|
}
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
err := validateNodeAnswers(node, fields, test.answers)
|
||||||
|
if test.want == "" && err != nil {
|
||||||
|
t.Fatalf("validateNodeAnswers() error = %v", err)
|
||||||
|
}
|
||||||
|
if test.want != "" && (err == nil || !strings.Contains(err.Error(), test.want)) {
|
||||||
|
t.Fatalf("validateNodeAnswers() error = %v, want containing %q", err, test.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateNodeAnswersRejectsAnswersForMessage(t *testing.T) {
|
||||||
|
err := validateNodeAnswers(model.SOPNode{Type: "message", Config: datatypes.JSON([]byte(`{}`))}, nil, map[string]interface{}{"pet_name": "豆包"})
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "不接受字段回答") {
|
||||||
|
t.Fatalf("validateNodeAnswers() error = %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -190,12 +190,16 @@ func (h *Handler) Validate(c *gin.Context) {
|
|||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
_, version, nodes, edges, err := h.loadLatest(id, p.TenantID)
|
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
|
||||||
}
|
}
|
||||||
problems := ValidateGraph(version.StartNodeKey, nodes, edges)
|
problems, err := h.validateForPublish(item, version.StartNodeKey, nodes, edges, p.TenantID)
|
||||||
|
if err != nil {
|
||||||
|
response.Error(c, http.StatusInternalServerError, "VALIDATION_FAILED", "校验流程失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
response.OK(c, gin.H{"valid": len(problems) == 0, "problems": problems})
|
response.OK(c, gin.H{"valid": len(problems) == 0, "problems": problems})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -210,7 +214,11 @@ func (h *Handler) SubmitReview(c *gin.Context) {
|
|||||||
response.Error(c, http.StatusConflict, "NO_DRAFT_VERSION", "没有可提交审核的草稿版本")
|
response.Error(c, http.StatusConflict, "NO_DRAFT_VERSION", "没有可提交审核的草稿版本")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
problems := ValidateGraph(version.StartNodeKey, nodes, edges)
|
problems, validationErr := h.validateForPublish(item, version.StartNodeKey, nodes, edges, p.TenantID)
|
||||||
|
if validationErr != nil {
|
||||||
|
response.Error(c, http.StatusInternalServerError, "VALIDATION_FAILED", "校验流程失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
if len(problems) > 0 {
|
if len(problems) > 0 {
|
||||||
response.Error(c, http.StatusUnprocessableEntity, "INVALID_GRAPH", problems[0])
|
response.Error(c, http.StatusUnprocessableEntity, "INVALID_GRAPH", problems[0])
|
||||||
return
|
return
|
||||||
@@ -240,7 +248,11 @@ func (h *Handler) Publish(c *gin.Context) {
|
|||||||
response.Error(c, http.StatusConflict, "NO_REVIEW_VERSION", "没有可发布的审核版本")
|
response.Error(c, http.StatusConflict, "NO_REVIEW_VERSION", "没有可发布的审核版本")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
problems := ValidateGraph(version.StartNodeKey, nodes, edges)
|
problems, validationErr := h.validateForPublish(item, version.StartNodeKey, nodes, edges, p.TenantID)
|
||||||
|
if validationErr != nil {
|
||||||
|
response.Error(c, http.StatusInternalServerError, "VALIDATION_FAILED", "校验流程失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
if len(problems) > 0 {
|
if len(problems) > 0 {
|
||||||
response.Error(c, http.StatusUnprocessableEntity, "INVALID_GRAPH", problems[0])
|
response.Error(c, http.StatusUnprocessableEntity, "INVALID_GRAPH", problems[0])
|
||||||
return
|
return
|
||||||
@@ -368,6 +380,22 @@ 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) validateForPublish(item model.SOP, startNodeKey string, nodes []model.SOPNode, edges []model.SOPEdge, tenantID uint64) ([]string, error) {
|
||||||
|
var fields []model.ScenarioField
|
||||||
|
if err := h.db.Where("scenario_id = ? AND tenant_id = ?", item.ScenarioID, tenantID).Find(&fields).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var cards []model.KnowledgeCard
|
||||||
|
if err := h.db.Where("scenario_id = ? AND tenant_id = ? AND status = ?", item.ScenarioID, tenantID, "published").Find(&cards).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
cardIDs := make(map[uint64]bool, len(cards))
|
||||||
|
for _, card := range cards {
|
||||||
|
cardIDs[card.ID] = true
|
||||||
|
}
|
||||||
|
return ValidateForPublish(startNodeKey, nodes, edges, ValidationContext{Fields: fields, PublishedKnowledgeCardIDs: cardIDs}), nil
|
||||||
|
}
|
||||||
|
|
||||||
func toModels(tenantID, versionID uint64, input graphInput) ([]model.SOPNode, []model.SOPEdge) {
|
func toModels(tenantID, versionID uint64, input graphInput) ([]model.SOPNode, []model.SOPEdge) {
|
||||||
nodes := make([]model.SOPNode, 0, len(input.Nodes))
|
nodes := make([]model.SOPNode, 0, len(input.Nodes))
|
||||||
for _, item := range input.Nodes {
|
for _, item := range input.Nodes {
|
||||||
|
|||||||
@@ -3,11 +3,18 @@ package sop
|
|||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"sort"
|
||||||
|
|
||||||
"git.iwork-ai.com/xdc/iqudo-top1/internal/model"
|
"git.iwork-ai.com/xdc/iqudo-top1/internal/model"
|
||||||
)
|
)
|
||||||
|
|
||||||
var allowedNodeTypes = map[string]bool{"start": true, "message": true, "question": true, "form": true, "choice": true, "condition": true, "knowledge": true, "escalate": true, "finish": true}
|
var allowedNodeTypes = map[string]bool{"start": true, "message": true, "question": true, "form": true, "choice": true, "condition": true, "knowledge": true, "escalate": true, "finish": true}
|
||||||
|
var allowedConditionOperators = map[string]bool{"equals": true, "not_equals": true, "contains": true, "greater_than": true, "less_than": true, "exists": true, "not_exists": true, "in": true}
|
||||||
|
|
||||||
|
type ValidationContext struct {
|
||||||
|
Fields []model.ScenarioField
|
||||||
|
PublishedKnowledgeCardIDs map[uint64]bool
|
||||||
|
}
|
||||||
|
|
||||||
func ValidateGraph(startNodeKey string, nodes []model.SOPNode, edges []model.SOPEdge) []string {
|
func ValidateGraph(startNodeKey string, nodes []model.SOPNode, edges []model.SOPEdge) []string {
|
||||||
var problems []string
|
var problems []string
|
||||||
@@ -29,6 +36,9 @@ func ValidateGraph(startNodeKey string, nodes []model.SOPNode, edges []model.SOP
|
|||||||
if !allowedNodeTypes[node.Type] {
|
if !allowedNodeTypes[node.Type] {
|
||||||
problems = append(problems, fmt.Sprintf("节点 %s 类型不支持", node.Title))
|
problems = append(problems, fmt.Sprintf("节点 %s 类型不支持", node.Title))
|
||||||
}
|
}
|
||||||
|
if len(node.Config) > 0 && !json.Valid(node.Config) {
|
||||||
|
problems = append(problems, fmt.Sprintf("节点“%s”的配置不是有效 JSON", node.Title))
|
||||||
|
}
|
||||||
if node.Type == "start" {
|
if node.Type == "start" {
|
||||||
startCount++
|
startCount++
|
||||||
}
|
}
|
||||||
@@ -47,6 +57,7 @@ func ValidateGraph(startNodeKey string, nodes []model.SOPNode, edges []model.SOP
|
|||||||
}
|
}
|
||||||
|
|
||||||
adjacency := make(map[string][]string)
|
adjacency := make(map[string][]string)
|
||||||
|
reverse := make(map[string][]string)
|
||||||
outgoing := make(map[string]int)
|
outgoing := make(map[string]int)
|
||||||
for _, edge := range edges {
|
for _, edge := range edges {
|
||||||
if _, exists := nodeMap[edge.SourceNodeKey]; !exists {
|
if _, exists := nodeMap[edge.SourceNodeKey]; !exists {
|
||||||
@@ -59,6 +70,7 @@ func ValidateGraph(startNodeKey string, nodes []model.SOPNode, edges []model.SOP
|
|||||||
problems = append(problems, fmt.Sprintf("连线 %s -> %s 的条件不是有效 JSON", edge.SourceNodeKey, edge.TargetNodeKey))
|
problems = append(problems, fmt.Sprintf("连线 %s -> %s 的条件不是有效 JSON", edge.SourceNodeKey, edge.TargetNodeKey))
|
||||||
}
|
}
|
||||||
adjacency[edge.SourceNodeKey] = append(adjacency[edge.SourceNodeKey], edge.TargetNodeKey)
|
adjacency[edge.SourceNodeKey] = append(adjacency[edge.SourceNodeKey], edge.TargetNodeKey)
|
||||||
|
reverse[edge.TargetNodeKey] = append(reverse[edge.TargetNodeKey], edge.SourceNodeKey)
|
||||||
outgoing[edge.SourceNodeKey]++
|
outgoing[edge.SourceNodeKey]++
|
||||||
}
|
}
|
||||||
for _, node := range nodes {
|
for _, node := range nodes {
|
||||||
@@ -86,5 +98,193 @@ func ValidateGraph(startNodeKey string, nodes []model.SOPNode, edges []model.SOP
|
|||||||
problems = append(problems, fmt.Sprintf("节点“%s”无法从开始节点到达", node.Title))
|
problems = append(problems, fmt.Sprintf("节点“%s”无法从开始节点到达", node.Title))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
canFinish := map[string]bool{}
|
||||||
|
var walkReverse func(string)
|
||||||
|
walkReverse = func(key string) {
|
||||||
|
if canFinish[key] {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
canFinish[key] = true
|
||||||
|
for _, previous := range reverse[key] {
|
||||||
|
walkReverse(previous)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, node := range nodes {
|
||||||
|
if node.Type == "finish" || node.Type == "escalate" {
|
||||||
|
walkReverse(node.NodeKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for key, node := range nodeMap {
|
||||||
|
if visited[key] && !canFinish[key] {
|
||||||
|
problems = append(problems, fmt.Sprintf("节点“%s”所在路径无法结束", node.Title))
|
||||||
|
}
|
||||||
|
}
|
||||||
return problems
|
return problems
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func ValidateForPublish(startNodeKey string, nodes []model.SOPNode, edges []model.SOPEdge, context ValidationContext) []string {
|
||||||
|
problems := ValidateGraph(startNodeKey, nodes, edges)
|
||||||
|
fieldMap := make(map[string]model.ScenarioField, len(context.Fields))
|
||||||
|
for _, field := range context.Fields {
|
||||||
|
fieldMap[field.FieldKey] = field
|
||||||
|
}
|
||||||
|
collected := map[string]bool{}
|
||||||
|
nodeMap := make(map[string]model.SOPNode, len(nodes))
|
||||||
|
adjacency := make(map[string][]string)
|
||||||
|
for _, node := range nodes {
|
||||||
|
nodeMap[node.NodeKey] = node
|
||||||
|
var config map[string]interface{}
|
||||||
|
if err := json.Unmarshal(node.Config, &config); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
switch node.Type {
|
||||||
|
case "question", "choice":
|
||||||
|
fieldKey, _ := config["field_key"].(string)
|
||||||
|
if fieldKey == "" {
|
||||||
|
problems = append(problems, fmt.Sprintf("节点“%s”没有配置采集字段", node.Title))
|
||||||
|
} else if _, exists := fieldMap[fieldKey]; !exists {
|
||||||
|
problems = append(problems, fmt.Sprintf("节点“%s”引用的字段 %s 不存在", node.Title, fieldKey))
|
||||||
|
} else {
|
||||||
|
collected[fieldKey] = true
|
||||||
|
}
|
||||||
|
case "form":
|
||||||
|
keys, _ := config["field_keys"].([]interface{})
|
||||||
|
if len(keys) == 0 {
|
||||||
|
problems = append(problems, fmt.Sprintf("节点“%s”没有配置表单字段", node.Title))
|
||||||
|
}
|
||||||
|
for _, value := range keys {
|
||||||
|
fieldKey, ok := value.(string)
|
||||||
|
if !ok || fieldKey == "" {
|
||||||
|
problems = append(problems, fmt.Sprintf("节点“%s”包含无效的表单字段", node.Title))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, exists := fieldMap[fieldKey]; !exists {
|
||||||
|
problems = append(problems, fmt.Sprintf("节点“%s”引用的字段 %s 不存在", node.Title, fieldKey))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
collected[fieldKey] = true
|
||||||
|
}
|
||||||
|
case "knowledge":
|
||||||
|
cardID := uint64FromJSON(config["knowledge_card_id"])
|
||||||
|
if cardID == 0 || !context.PublishedKnowledgeCardIDs[cardID] {
|
||||||
|
problems = append(problems, fmt.Sprintf("节点“%s”没有关联已发布的知识卡", node.Title))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, field := range context.Fields {
|
||||||
|
if field.Required && !collected[field.FieldKey] {
|
||||||
|
problems = append(problems, fmt.Sprintf("必填字段“%s”没有对应的采集节点", field.FieldName))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
defaultPaths := map[string]int{}
|
||||||
|
for _, edge := range edges {
|
||||||
|
adjacency[edge.SourceNodeKey] = append(adjacency[edge.SourceNodeKey], edge.TargetNodeKey)
|
||||||
|
if isDefaultCondition(edge.Condition) {
|
||||||
|
defaultPaths[edge.SourceNodeKey]++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var rule interface{}
|
||||||
|
if err := json.Unmarshal(edge.Condition, &rule); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
validateCondition(rule, fieldMap, fmt.Sprintf("路径 %s -> %s", edge.SourceNodeKey, edge.TargetNodeKey), &problems)
|
||||||
|
}
|
||||||
|
for source, count := range defaultPaths {
|
||||||
|
if count > 1 {
|
||||||
|
problems = append(problems, fmt.Sprintf("节点 %s 配置了多条默认路径", source))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, node := range nodes {
|
||||||
|
var config map[string]interface{}
|
||||||
|
_ = json.Unmarshal(node.Config, &config)
|
||||||
|
if config["risk_level"] != "high" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !canReachType(node.NodeKey, "escalate", nodeMap, adjacency) {
|
||||||
|
problems = append(problems, fmt.Sprintf("高风险节点“%s”没有明确的转人工或转诊路径", node.Title))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Strings(problems)
|
||||||
|
return problems
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateCondition(value interface{}, fields map[string]model.ScenarioField, label string, problems *[]string) {
|
||||||
|
rule, ok := value.(map[string]interface{})
|
||||||
|
if !ok {
|
||||||
|
*problems = append(*problems, label+"的条件结构不正确")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, group := range []string{"all", "any"} {
|
||||||
|
if raw, exists := rule[group]; exists {
|
||||||
|
items, ok := raw.([]interface{})
|
||||||
|
if !ok || len(items) == 0 {
|
||||||
|
*problems = append(*problems, label+"的组合条件不能为空")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, item := range items {
|
||||||
|
validateCondition(item, fields, label, problems)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
field, _ := rule["field"].(string)
|
||||||
|
operator, _ := rule["operator"].(string)
|
||||||
|
if _, exists := fields[field]; field == "" || !exists {
|
||||||
|
*problems = append(*problems, fmt.Sprintf("%s 引用了不存在的字段 %s", label, field))
|
||||||
|
}
|
||||||
|
if !allowedConditionOperators[operator] {
|
||||||
|
*problems = append(*problems, fmt.Sprintf("%s 使用了不支持的运算符 %s", label, operator))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func isDefaultCondition(value []byte) bool {
|
||||||
|
if len(value) == 0 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
var condition interface{}
|
||||||
|
if err := json.Unmarshal(value, &condition); err != nil || condition == nil {
|
||||||
|
return condition == nil && err == nil
|
||||||
|
}
|
||||||
|
object, ok := condition.(map[string]interface{})
|
||||||
|
return ok && len(object) == 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func uint64FromJSON(value interface{}) uint64 {
|
||||||
|
switch typed := value.(type) {
|
||||||
|
case float64:
|
||||||
|
if typed > 0 {
|
||||||
|
return uint64(typed)
|
||||||
|
}
|
||||||
|
case uint64:
|
||||||
|
return typed
|
||||||
|
case int:
|
||||||
|
if typed > 0 {
|
||||||
|
return uint64(typed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func canReachType(start, nodeType string, nodes map[string]model.SOPNode, adjacency map[string][]string) bool {
|
||||||
|
visited := map[string]bool{}
|
||||||
|
var walk func(string) bool
|
||||||
|
walk = func(key string) bool {
|
||||||
|
if visited[key] {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
visited[key] = true
|
||||||
|
if nodes[key].Type == nodeType {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
for _, next := range adjacency[key] {
|
||||||
|
if walk(next) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return walk(start)
|
||||||
|
}
|
||||||
|
|||||||
106
internal/sop/validator_test.go
Normal file
106
internal/sop/validator_test.go
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
package sop
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.iwork-ai.com/xdc/iqudo-top1/internal/model"
|
||||||
|
"gorm.io/datatypes"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestValidateForPublishValidHighRiskFlow(t *testing.T) {
|
||||||
|
nodes, edges, context := validPublishGraph()
|
||||||
|
if problems := ValidateForPublish("start", nodes, edges, context); len(problems) != 0 {
|
||||||
|
t.Fatalf("ValidateForPublish() problems = %v", problems)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateForPublishBusinessRules(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
mutate func(*[]model.SOPNode, *[]model.SOPEdge, *ValidationContext)
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{name: "missing required collection", mutate: func(_ *[]model.SOPNode, _ *[]model.SOPEdge, context *ValidationContext) {
|
||||||
|
context.Fields = append(context.Fields, model.ScenarioField{FieldKey: "symptom", FieldName: "主要症状", Required: true})
|
||||||
|
}, want: "没有对应的采集节点"},
|
||||||
|
{name: "unknown condition field", mutate: func(_ *[]model.SOPNode, edges *[]model.SOPEdge, _ *ValidationContext) {
|
||||||
|
(*edges)[2].Condition = jsonData(`{"field":"missing","operator":"equals","value":true}`)
|
||||||
|
}, want: "不存在的字段 missing"},
|
||||||
|
{name: "invalid operator", mutate: func(_ *[]model.SOPNode, edges *[]model.SOPEdge, _ *ValidationContext) {
|
||||||
|
(*edges)[2].Condition = jsonData(`{"field":"emergency","operator":"matches","value":true}`)
|
||||||
|
}, want: "不支持的运算符 matches"},
|
||||||
|
{name: "unpublished knowledge", mutate: func(_ *[]model.SOPNode, _ *[]model.SOPEdge, context *ValidationContext) {
|
||||||
|
context.PublishedKnowledgeCardIDs = map[uint64]bool{}
|
||||||
|
}, want: "没有关联已发布的知识卡"},
|
||||||
|
{name: "duplicate default path", mutate: func(_ *[]model.SOPNode, edges *[]model.SOPEdge, _ *ValidationContext) {
|
||||||
|
(*edges)[1].Condition = jsonData(`{}`)
|
||||||
|
}, want: "配置了多条默认路径"},
|
||||||
|
{name: "high risk without escalation", mutate: func(_ *[]model.SOPNode, edges *[]model.SOPEdge, _ *ValidationContext) {
|
||||||
|
(*edges)[1].TargetNodeKey = "finish"
|
||||||
|
}, want: "没有明确的转人工或转诊路径"},
|
||||||
|
}
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
nodes, edges, context := validPublishGraph()
|
||||||
|
test.mutate(&nodes, &edges, &context)
|
||||||
|
problems := ValidateForPublish("start", nodes, edges, context)
|
||||||
|
if !containsProblem(problems, test.want) {
|
||||||
|
t.Fatalf("ValidateForPublish() problems = %v, want containing %q", problems, test.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateGraphRejectsNonTerminatingCycle(t *testing.T) {
|
||||||
|
nodes := []model.SOPNode{
|
||||||
|
{NodeKey: "start", Type: "start", Title: "开始", Config: jsonData(`{}`)},
|
||||||
|
{NodeKey: "loop", Type: "message", Title: "循环", Config: jsonData(`{}`)},
|
||||||
|
{NodeKey: "finish", Type: "finish", Title: "结束", Config: jsonData(`{}`)},
|
||||||
|
}
|
||||||
|
edges := []model.SOPEdge{
|
||||||
|
{SourceNodeKey: "start", TargetNodeKey: "loop", Condition: jsonData(`{}`)},
|
||||||
|
{SourceNodeKey: "loop", TargetNodeKey: "loop", Condition: jsonData(`{}`)},
|
||||||
|
}
|
||||||
|
if problems := ValidateGraph("start", nodes, edges); !containsProblem(problems, "所在路径无法结束") {
|
||||||
|
t.Fatalf("ValidateGraph() problems = %v", problems)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsDefaultConditionAllowsJSONWhitespace(t *testing.T) {
|
||||||
|
if !isDefaultCondition([]byte(` { } `)) {
|
||||||
|
t.Fatal("isDefaultCondition() should accept an empty JSON object with whitespace")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func validPublishGraph() ([]model.SOPNode, []model.SOPEdge, ValidationContext) {
|
||||||
|
nodes := []model.SOPNode{
|
||||||
|
{NodeKey: "start", Type: "start", Title: "开始", Config: jsonData(`{}`)},
|
||||||
|
{NodeKey: "screen", Type: "form", Title: "急症筛查", Config: jsonData(`{"field_keys":["emergency"],"risk_level":"high"}`)},
|
||||||
|
{NodeKey: "knowledge", Type: "knowledge", Title: "用药原则", Config: jsonData(`{"knowledge_card_id":1}`)},
|
||||||
|
{NodeKey: "escalate", Type: "escalate", Title: "转诊", Config: jsonData(`{}`)},
|
||||||
|
{NodeKey: "finish", Type: "finish", Title: "结束", Config: jsonData(`{}`)},
|
||||||
|
}
|
||||||
|
edges := []model.SOPEdge{
|
||||||
|
{SourceNodeKey: "start", TargetNodeKey: "screen", Condition: jsonData(`{}`)},
|
||||||
|
{SourceNodeKey: "screen", TargetNodeKey: "escalate", Condition: jsonData(`{"field":"emergency","operator":"equals","value":true}`)},
|
||||||
|
{SourceNodeKey: "screen", TargetNodeKey: "knowledge", Condition: jsonData(`{}`)},
|
||||||
|
{SourceNodeKey: "knowledge", TargetNodeKey: "finish", Condition: jsonData(`{}`)},
|
||||||
|
}
|
||||||
|
context := ValidationContext{
|
||||||
|
Fields: []model.ScenarioField{{FieldKey: "emergency", FieldName: "是否急症", Required: true}},
|
||||||
|
PublishedKnowledgeCardIDs: map[uint64]bool{1: true},
|
||||||
|
}
|
||||||
|
return nodes, edges, context
|
||||||
|
}
|
||||||
|
|
||||||
|
func jsonData(value string) datatypes.JSON { return datatypes.JSON([]byte(value)) }
|
||||||
|
|
||||||
|
func containsProblem(problems []string, want string) bool {
|
||||||
|
for _, problem := range problems {
|
||||||
|
if strings.Contains(problem, want) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
@@ -222,11 +222,12 @@ func seedSOP(tx *gorm.DB, tenantID, userID, scenarioID uint64, knowledgeIDs map[
|
|||||||
}
|
}
|
||||||
var seededPublished int64
|
var seededPublished int64
|
||||||
if err := tx.Table("sop_nodes n").Joins("JOIN sop_versions v ON v.id = n.sop_version_id").Where(
|
if err := tx.Table("sop_nodes n").Joins("JOIN sop_versions v ON v.id = n.sop_version_id").Where(
|
||||||
"v.sop_id = ? AND v.status = ? AND n.node_key IN ?", sop.ID, "published", []string{"emergency_screen", "emergency_escalate", "medication_safety"},
|
"v.sop_id = ? AND n.node_key = ? AND JSON_UNQUOTE(JSON_EXTRACT(n.config, '$.seed_key')) = ?",
|
||||||
|
sop.ID, "start", "pet-doctor-v2",
|
||||||
).Count(&seededPublished).Error; err != nil {
|
).Count(&seededPublished).Error; err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if seededPublished == 3 {
|
if seededPublished > 0 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -291,12 +292,12 @@ func seedSOP(tx *gorm.DB, tenantID, userID, scenarioID uint64, knowledgeIDs map[
|
|||||||
|
|
||||||
func petNodes(knowledgeCardID uint64) []nodeDefinition {
|
func petNodes(knowledgeCardID uint64) []nodeDefinition {
|
||||||
return []nodeDefinition{
|
return []nodeDefinition{
|
||||||
{Key: "start", Type: "start", Title: "开始", Config: map[string]interface{}{"seed_key": "pet-doctor-v1"}},
|
{Key: "start", Type: "start", Title: "开始", Config: map[string]interface{}{"seed_key": "pet-doctor-v2"}},
|
||||||
{Key: "opening", Type: "message", Title: "说明问诊流程", Content: "您好,我会先记录宠物的基本信息和症状,并优先确认是否存在需要立即就医的情况。线上沟通不能替代医生检查。", Config: map[string]interface{}{}},
|
{Key: "opening", Type: "message", Title: "说明问诊流程", Content: "您好,我会先记录宠物的基本信息和症状,并优先确认是否存在需要立即就医的情况。线上沟通不能替代医生检查。", Config: map[string]interface{}{}},
|
||||||
{Key: "basic_info", Type: "form", Title: "采集宠物基本信息", Content: "请确认宠物名称、种类、年龄、体重、性别和绝育情况。", Config: map[string]interface{}{"field_keys": []string{"pet_name", "pet_type", "pet_age", "pet_weight", "pet_sex", "is_neutered"}}},
|
{Key: "basic_info", Type: "form", Title: "采集宠物基本信息", Content: "请确认宠物名称、种类、年龄、体重、性别和绝育情况。", Config: map[string]interface{}{"field_keys": []string{"pet_name", "pet_type", "pet_age", "pet_weight", "pet_sex", "is_neutered"}}},
|
||||||
{Key: "chief_complaint", Type: "form", Title: "记录主诉", Content: "请让宠物主人按时间顺序描述最主要的症状和持续时间。", Config: map[string]interface{}{"field_keys": []string{"symptom", "symptom_duration"}}},
|
{Key: "chief_complaint", Type: "form", Title: "记录主诉", Content: "请让宠物主人按时间顺序描述最主要的症状和持续时间。", Config: map[string]interface{}{"field_keys": []string{"symptom", "symptom_duration"}}},
|
||||||
{Key: "emergency_screen", Type: "form", Title: "筛查急症红旗", Content: "逐项确认当前是否存在以下急症表现。任意一项为“是”都应优先转诊。", Config: map[string]interface{}{"field_keys": []string{"breathing_difficulty", "active_bleeding", "convulsion", "unable_to_urinate", "toxin_exposure", "has_emergency_sign"}}},
|
{Key: "emergency_screen", Type: "form", Title: "筛查急症红旗", Content: "逐项确认当前是否存在以下急症表现。任意一项为“是”都应优先转诊。", Config: map[string]interface{}{"field_keys": []string{"breathing_difficulty", "active_bleeding", "convulsion", "unable_to_urinate", "toxin_exposure", "has_emergency_sign"}}},
|
||||||
{Key: "emergency_check", Type: "condition", Title: "判断是否需要立即转诊", Content: "系统根据急症筛查结果自动分流。", Config: map[string]interface{}{}},
|
{Key: "emergency_check", Type: "condition", Title: "判断是否需要立即转诊", Content: "系统根据急症筛查结果自动分流。", Config: map[string]interface{}{"risk_level": "high"}},
|
||||||
{Key: "emergency_escalate", Type: "escalate", Title: "立即急诊或转诊", Content: "存在急症红旗,请停止常规线上问诊,建议立即前往最近的宠物急诊,并提前联系接诊机构。不要自行喂药、催吐或强行喂食。", Config: map[string]interface{}{"action": "urgent_referral"}},
|
{Key: "emergency_escalate", Type: "escalate", Title: "立即急诊或转诊", Content: "存在急症红旗,请停止常规线上问诊,建议立即前往最近的宠物急诊,并提前联系接诊机构。不要自行喂药、催吐或强行喂食。", Config: map[string]interface{}{"action": "urgent_referral"}},
|
||||||
{Key: "history_collection", Type: "form", Title: "补充状态与病史", Content: "继续了解食欲、精神状态、消化道表现、近期用药和药物过敏史。", Config: map[string]interface{}{"field_keys": []string{"appetite", "spirit_status", "vomiting", "diarrhea", "medication_history", "allergy_history"}}},
|
{Key: "history_collection", Type: "form", Title: "补充状态与病史", Content: "继续了解食欲、精神状态、消化道表现、近期用药和药物过敏史。", Config: map[string]interface{}{"field_keys": []string{"appetite", "spirit_status", "vomiting", "diarrhea", "medication_history", "allergy_history"}}},
|
||||||
{Key: "medication_intent", Type: "question", Title: "确认用药诉求", Content: "请确认宠物主人是否正在咨询具体药物、剂量或疗程。", Config: map[string]interface{}{"field_key": "wants_medication", "required": true}},
|
{Key: "medication_intent", Type: "question", Title: "确认用药诉求", Content: "请确认宠物主人是否正在咨询具体药物、剂量或疗程。", Config: map[string]interface{}{"field_key": "wants_medication", "required": true}},
|
||||||
|
|||||||
1
web/dist/assets/ExecuteView-B0_AkMSt.js
vendored
Normal file
1
web/dist/assets/ExecuteView-B0_AkMSt.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
web/dist/assets/ExecuteView-DQwv99Fb.js
vendored
1
web/dist/assets/ExecuteView-DQwv99Fb.js
vendored
File diff suppressed because one or more lines are too long
1
web/dist/assets/ExecuteView-_c1ZIw15.css
vendored
Normal file
1
web/dist/assets/ExecuteView-_c1ZIw15.css
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
.execution-shell[data-v-d343fa7e]{max-width:1220px}.sop-catalog[data-v-d343fa7e]{grid-template-columns:repeat(2,minmax(0,1fr));gap:14px;display:grid}.sop-entry[data-v-d343fa7e]{text-align:left;cursor:pointer;grid-template-columns:40px 1fr 42px;align-items:start;gap:14px;min-height:150px;padding:22px;display:grid}.sop-entry[data-v-d343fa7e]:hover{border-color:#9ac8b8;box-shadow:0 8px 24px #20282512}.entry-seq[data-v-d343fa7e]{color:#8c9893;font-family:monospace;font-size:12px}.sop-entry small[data-v-d343fa7e]{color:var(--green);font-weight:650}.sop-entry h2[data-v-d343fa7e]{margin:8px 0 7px;font-family:Noto Serif SC,serif;font-size:20px}.sop-entry p[data-v-d343fa7e]{color:var(--muted);margin:0;line-height:1.6}.play[data-v-d343fa7e]{color:#fff;background:#202825;border-radius:4px;place-items:center;width:38px;height:38px;font-size:18px;display:grid}.run-workspace[data-v-d343fa7e]{grid-template-columns:270px minmax(0,1fr);gap:18px;display:grid}.run-context[data-v-d343fa7e]{color:#fff;background:#202825;border-radius:6px;align-self:start;padding:24px;position:sticky;top:86px}.run-label[data-v-d343fa7e]{color:#72c9ab;font-size:11px;font-weight:700}.run-context h2[data-v-d343fa7e]{margin:12px 0 8px;font-family:Noto Serif SC,serif}.run-context>p[data-v-d343fa7e]{color:#aebdb7;margin:0 0 28px;line-height:1.6}.context-meta[data-v-d343fa7e]{border-top:1px solid #3a4742;justify-content:space-between;align-items:center;padding:13px 0;display:flex}.context-meta span[data-v-d343fa7e]{color:#9eada7;font-size:12px}.privacy-note[data-v-d343fa7e]{color:#889b93;gap:8px;margin-top:28px;font-size:11px;display:flex}.conversation[data-v-d343fa7e]{min-height:590px;padding:30px 34px}.conversation-progress[data-v-d343fa7e]{color:#66736d;align-items:center;gap:8px;font-size:12px;display:flex}.conversation-progress span[data-v-d343fa7e]{background:#d08736;border-radius:50%;width:8px;height:8px;box-shadow:0 0 0 4px #faefe1}.conversation-progress span.done[data-v-d343fa7e]{background:var(--green);box-shadow:0 0 0 4px #e4f1ec}.node-content[data-v-d343fa7e]{padding:44px 0 26px}.node-content small[data-v-d343fa7e]{color:var(--green);font-size:10px;font-weight:800}.node-content h1[data-v-d343fa7e]{margin:8px 0 22px;font-family:Noto Serif SC,serif;font-size:28px}.node-content blockquote[data-v-d343fa7e]{color:#29342f;border-left:3px solid var(--green);background:#f2f6f4;margin:0;padding:18px 20px;font-size:17px;line-height:1.8}.answer-form[data-v-d343fa7e]{max-width:680px}.choice-group[data-v-d343fa7e]{flex-wrap:wrap;display:flex}.run-actions[data-v-d343fa7e]{border-top:1px solid var(--line);color:var(--muted);justify-content:space-between;align-items:center;gap:16px;margin:24px -34px -30px;padding:18px 34px;font-size:12px;display:flex}.completed-state[data-v-d343fa7e]{text-align:center;padding:40px 0}.completed-state>span[data-v-d343fa7e]{color:var(--green);font-size:50px}.completed-state h3[data-v-d343fa7e]{margin:14px 0 8px;font-family:Noto Serif SC,serif;font-size:24px}.completed-state p[data-v-d343fa7e]{color:var(--muted);margin:0 0 24px}@media (width<=800px){.sop-catalog[data-v-d343fa7e],.run-workspace[data-v-d343fa7e]{grid-template-columns:1fr}.run-context[data-v-d343fa7e]{position:static}.conversation[data-v-d343fa7e]{padding:22px}.run-actions[data-v-d343fa7e]{margin:24px -22px -22px;padding:16px 22px}.run-actions span[data-v-d343fa7e]{display:none}}
|
||||||
1
web/dist/assets/ExecuteView-iUcBfH4l.css
vendored
1
web/dist/assets/ExecuteView-iUcBfH4l.css
vendored
@@ -1 +0,0 @@
|
|||||||
.execution-shell[data-v-d94f7da9]{max-width:1220px}.sop-catalog[data-v-d94f7da9]{grid-template-columns:repeat(2,minmax(0,1fr));gap:14px;display:grid}.sop-entry[data-v-d94f7da9]{text-align:left;cursor:pointer;grid-template-columns:40px 1fr 42px;align-items:start;gap:14px;min-height:150px;padding:22px;display:grid}.sop-entry[data-v-d94f7da9]:hover{border-color:#9ac8b8;box-shadow:0 8px 24px #20282512}.entry-seq[data-v-d94f7da9]{color:#8c9893;font-family:monospace;font-size:12px}.sop-entry small[data-v-d94f7da9]{color:var(--green);font-weight:650}.sop-entry h2[data-v-d94f7da9]{margin:8px 0 7px;font-family:Noto Serif SC,serif;font-size:20px}.sop-entry p[data-v-d94f7da9]{color:var(--muted);margin:0;line-height:1.6}.play[data-v-d94f7da9]{color:#fff;background:#202825;border-radius:4px;place-items:center;width:38px;height:38px;font-size:18px;display:grid}.run-workspace[data-v-d94f7da9]{grid-template-columns:270px minmax(0,1fr);gap:18px;display:grid}.run-context[data-v-d94f7da9]{color:#fff;background:#202825;border-radius:6px;align-self:start;padding:24px;position:sticky;top:86px}.run-label[data-v-d94f7da9]{color:#72c9ab;font-size:11px;font-weight:700}.run-context h2[data-v-d94f7da9]{margin:12px 0 8px;font-family:Noto Serif SC,serif}.run-context>p[data-v-d94f7da9]{color:#aebdb7;margin:0 0 28px;line-height:1.6}.context-meta[data-v-d94f7da9]{border-top:1px solid #3a4742;justify-content:space-between;align-items:center;padding:13px 0;display:flex}.context-meta span[data-v-d94f7da9]{color:#9eada7;font-size:12px}.privacy-note[data-v-d94f7da9]{color:#889b93;gap:8px;margin-top:28px;font-size:11px;display:flex}.conversation[data-v-d94f7da9]{min-height:590px;padding:30px 34px}.conversation-progress[data-v-d94f7da9]{color:#66736d;align-items:center;gap:8px;font-size:12px;display:flex}.conversation-progress span[data-v-d94f7da9]{background:#d08736;border-radius:50%;width:8px;height:8px;box-shadow:0 0 0 4px #faefe1}.conversation-progress span.done[data-v-d94f7da9]{background:var(--green);box-shadow:0 0 0 4px #e4f1ec}.node-content[data-v-d94f7da9]{padding:44px 0 26px}.node-content small[data-v-d94f7da9]{color:var(--green);font-size:10px;font-weight:800}.node-content h1[data-v-d94f7da9]{margin:8px 0 22px;font-family:Noto Serif SC,serif;font-size:28px}.node-content blockquote[data-v-d94f7da9]{color:#29342f;border-left:3px solid var(--green);background:#f2f6f4;margin:0;padding:18px 20px;font-size:17px;line-height:1.8}.answer-form[data-v-d94f7da9]{max-width:680px}.choice-group[data-v-d94f7da9]{flex-wrap:wrap;display:flex}.run-actions[data-v-d94f7da9]{border-top:1px solid var(--line);color:var(--muted);justify-content:space-between;align-items:center;gap:16px;margin:24px -34px -30px;padding:18px 34px;font-size:12px;display:flex}.completed-state[data-v-d94f7da9]{text-align:center;padding:40px 0}.completed-state>span[data-v-d94f7da9]{color:var(--green);font-size:50px}.completed-state h3[data-v-d94f7da9]{margin:14px 0 8px;font-family:Noto Serif SC,serif;font-size:24px}.completed-state p[data-v-d94f7da9]{color:var(--muted);margin:0 0 24px}@media (width<=800px){.sop-catalog[data-v-d94f7da9],.run-workspace[data-v-d94f7da9]{grid-template-columns:1fr}.run-context[data-v-d94f7da9]{position:static}.conversation[data-v-d94f7da9]{padding:22px}.run-actions[data-v-d94f7da9]{margin:24px -22px -22px;padding:16px 22px}.run-actions span[data-v-d94f7da9]{display:none}}
|
|
||||||
File diff suppressed because one or more lines are too long
2
web/dist/index.html
vendored
2
web/dist/index.html
vendored
@@ -5,7 +5,7 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<meta name="theme-color" content="#202825" />
|
<meta name="theme-color" content="#202825" />
|
||||||
<title>销冠 SOP 平台</title>
|
<title>销冠 SOP 平台</title>
|
||||||
<script type="module" crossorigin src="/assets/index-CsZhTuSf.js"></script>
|
<script type="module" crossorigin src="/assets/index-BETdHWCY.js"></script>
|
||||||
<link rel="modulepreload" crossorigin href="/assets/client-CO11mUW5.js">
|
<link rel="modulepreload" crossorigin href="/assets/client-CO11mUW5.js">
|
||||||
<link rel="modulepreload" crossorigin href="/assets/config-provider-kwhtQ-D4.js">
|
<link rel="modulepreload" crossorigin href="/assets/config-provider-kwhtQ-D4.js">
|
||||||
<link rel="modulepreload" crossorigin href="/assets/auth-D7KJ41TQ.js">
|
<link rel="modulepreload" crossorigin href="/assets/auth-D7KJ41TQ.js">
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ const currentConfig=computed(()=>active.value?.node.config||{})
|
|||||||
const currentFields=computed(()=>{if(!active.value)return[];const node=active.value.node;if(node.type==='question'||node.type==='choice')return active.value.fields.filter(f=>f.field_key===currentConfig.value.field_key);if(node.type==='form')return active.value.fields.filter(f=>(currentConfig.value.field_keys||[]).includes(f.field_key));return[]})
|
const currentFields=computed(()=>{if(!active.value)return[];const node=active.value.node;if(node.type==='question'||node.type==='choice')return active.value.fields.filter(f=>f.field_key===currentConfig.value.field_key);if(node.type==='form')return active.value.fields.filter(f=>(currentConfig.value.field_keys||[]).includes(f.field_key));return[]})
|
||||||
async function load(){loading.value=true;try{sops.value=(await api.get<{items:PublishedSOP[]}>('/published-sops')).items}catch(error){message.error(apiMessage(error))}finally{loading.value=false}}
|
async function load(){loading.value=true;try{sops.value=(await api.get<{items:PublishedSOP[]}>('/published-sops')).items}catch(error){message.error(apiMessage(error))}finally{loading.value=false}}
|
||||||
async function start(sopID:number){try{active.value=await api.post<RunView>('/runs',{sop_id:sopID});Object.keys(answers).forEach(k=>delete answers[k])}catch(error){message.error(apiMessage(error))}}
|
async function start(sopID:number){try{active.value=await api.post<RunView>('/runs',{sop_id:sopID});Object.keys(answers).forEach(k=>delete answers[k])}catch(error){message.error(apiMessage(error))}}
|
||||||
async function next(){if(!active.value)return;submitting.value=true;try{active.value=await api.post<RunView>(`/runs/${active.value.run.id}/answer`,{answers:{...answers}});Object.keys(answers).forEach(k=>delete answers[k])}catch(error){message.error(apiMessage(error))}finally{submitting.value=false}}
|
async function next(){if(!active.value)return;submitting.value=true;try{active.value=await api.post<RunView>(`/runs/${active.value.run.id}/answer`,{node_key:active.value.node.node_key,answers:{...answers}});Object.keys(answers).forEach(k=>delete answers[k])}catch(error){message.error(apiMessage(error))}finally{submitting.value=false}}
|
||||||
function inputFor(field:ScenarioField){return field.field_type}
|
function inputFor(field:ScenarioField){return field.field_type}
|
||||||
function reset(){active.value=null;load()}
|
function reset(){active.value=null;load()}
|
||||||
onMounted(load)
|
onMounted(load)
|
||||||
|
|||||||
Reference in New Issue
Block a user