feat: add complete pet consultation SOP
This commit is contained in:
5
Makefile
5
Makefile
@@ -1,6 +1,6 @@
|
|||||||
WEB_DIR := web
|
WEB_DIR := web
|
||||||
|
|
||||||
.PHONY: web-install web-build build test vet check
|
.PHONY: web-install web-build build test vet check seed-pet-doctor
|
||||||
|
|
||||||
web-install:
|
web-install:
|
||||||
cd $(WEB_DIR) && npm install
|
cd $(WEB_DIR) && npm install
|
||||||
@@ -18,3 +18,6 @@ vet:
|
|||||||
go vet ./...
|
go vet ./...
|
||||||
|
|
||||||
check: test vet
|
check: test vet
|
||||||
|
|
||||||
|
seed-pet-doctor:
|
||||||
|
go run ./scripts/seed-pet-doctor
|
||||||
|
|||||||
30
README.md
30
README.md
@@ -1,3 +1,31 @@
|
|||||||
# iqudo-top1
|
# iqudo-top1
|
||||||
|
|
||||||
销冠
|
场景化销售话术与 SOP 平台。
|
||||||
|
|
||||||
|
## 本地运行
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mysql -uroot -p < scripts/create-databases.sql
|
||||||
|
make web-install
|
||||||
|
make build
|
||||||
|
./bin/iqudo-top1
|
||||||
|
```
|
||||||
|
|
||||||
|
默认配置位于 `configs/config.yml`,可以通过环境变量覆盖。
|
||||||
|
|
||||||
|
## 宠物医生问诊问药示范场景
|
||||||
|
|
||||||
|
数据库迁移和管理员初始化完成后执行:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make seed-pet-doctor
|
||||||
|
```
|
||||||
|
|
||||||
|
该命令会幂等地创建或完善以下配置:
|
||||||
|
|
||||||
|
- 宠物医生问诊问药场景
|
||||||
|
- 21 个动态问诊字段
|
||||||
|
- 急症红旗征象和问诊用药安全原则知识卡
|
||||||
|
- 包含普通问诊与急症转诊分支的已发布 SOP
|
||||||
|
|
||||||
|
示范场景是配置数据,通用流程引擎不包含宠物行业专用判断。
|
||||||
|
|||||||
332
scripts/seed-pet-doctor/main.go
Normal file
332
scripts/seed-pet-doctor/main.go
Normal file
@@ -0,0 +1,332 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.iwork-ai.com/xdc/iqudo-top1/internal/config"
|
||||||
|
"git.iwork-ai.com/xdc/iqudo-top1/internal/database"
|
||||||
|
"git.iwork-ai.com/xdc/iqudo-top1/internal/logger"
|
||||||
|
"git.iwork-ai.com/xdc/iqudo-top1/internal/model"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
"gorm.io/datatypes"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
type fieldDefinition struct {
|
||||||
|
Key string
|
||||||
|
Name string
|
||||||
|
Type string
|
||||||
|
Required bool
|
||||||
|
Options []string
|
||||||
|
Validation map[string]interface{}
|
||||||
|
}
|
||||||
|
|
||||||
|
type nodeDefinition struct {
|
||||||
|
Key string
|
||||||
|
Type string
|
||||||
|
Title string
|
||||||
|
Content string
|
||||||
|
Config map[string]interface{}
|
||||||
|
}
|
||||||
|
|
||||||
|
type edgeDefinition struct {
|
||||||
|
Source string
|
||||||
|
Target string
|
||||||
|
Condition map[string]interface{}
|
||||||
|
Priority int
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
configDir := flag.String("config-dir", "configs", "configuration directory")
|
||||||
|
environment := flag.String("env", "", "runtime environment")
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
|
cfg, err := config.Load(config.LoadOptions{Environment: *environment, ConfigDir: *configDir})
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "load configuration: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
log, err := logger.New(cfg.App.Env, cfg.App.Name+"-seed")
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "create logger: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
defer log.Sync()
|
||||||
|
|
||||||
|
db, err := database.Open(cfg.Database, log)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal("connect database", zap.Error(err))
|
||||||
|
}
|
||||||
|
if err := seed(db); err != nil {
|
||||||
|
log.Fatal("seed pet doctor scenario", zap.Error(err))
|
||||||
|
}
|
||||||
|
log.Info("pet doctor scenario is ready")
|
||||||
|
}
|
||||||
|
|
||||||
|
func seed(db *gorm.DB) error {
|
||||||
|
return db.Transaction(func(tx *gorm.DB) error {
|
||||||
|
tenant, user, err := seedOwner(tx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
scenario, err := seedScenario(tx, tenant.ID, user.ID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := seedFields(tx, tenant.ID, scenario.ID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
knowledgeIDs, err := seedKnowledgeCards(tx, tenant.ID, user.ID, scenario.ID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := seedSOP(tx, tenant.ID, user.ID, scenario.ID, knowledgeIDs); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func seedOwner(tx *gorm.DB) (model.Tenant, model.User, error) {
|
||||||
|
var tenant model.Tenant
|
||||||
|
if err := tx.Where("slug = ?", "default").First(&tenant).Error; err != nil {
|
||||||
|
return tenant, model.User{}, fmt.Errorf("find default tenant: %w", err)
|
||||||
|
}
|
||||||
|
var user model.User
|
||||||
|
if err := tx.Table("users u").Select("u.*").Joins("JOIN tenant_members tm ON tm.user_id = u.id").Joins("JOIN roles r ON r.id = tm.role_id").Where("tm.tenant_id = ? AND r.code = ? AND u.status = ?", tenant.ID, "admin", "active").Order("u.id").First(&user).Error; err != nil {
|
||||||
|
return tenant, user, fmt.Errorf("find administrator: %w", err)
|
||||||
|
}
|
||||||
|
return tenant, user, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func seedScenario(tx *gorm.DB, tenantID, userID uint64) (model.Scenario, error) {
|
||||||
|
var scenario model.Scenario
|
||||||
|
err := tx.Where("tenant_id = ? AND name = ?", tenantID, "宠物医生问诊问药").FirstOrCreate(&scenario, model.Scenario{
|
||||||
|
TenantID: tenantID, Name: "宠物医生问诊问药", CreatedBy: userID,
|
||||||
|
}).Error
|
||||||
|
if err != nil {
|
||||||
|
return scenario, fmt.Errorf("find or create scenario: %w", err)
|
||||||
|
}
|
||||||
|
updates := map[string]interface{}{
|
||||||
|
"industry": "宠物医疗", "role_name": "宠物医生、助理、客服",
|
||||||
|
"goal": "标准化收集宠物基本信息、主诉和病史,优先识别急症,在医生评估前提供安全且一致的沟通指引。",
|
||||||
|
"trigger_text": "宠物主人通过门店、电话或在线渠道咨询症状、检查、用药或是否需要就医时进入本场景。",
|
||||||
|
"visibility": "tenant", "status": "active",
|
||||||
|
}
|
||||||
|
if err := tx.Model(&scenario).Updates(updates).Error; err != nil {
|
||||||
|
return scenario, fmt.Errorf("update scenario: %w", err)
|
||||||
|
}
|
||||||
|
return scenario, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func seedFields(tx *gorm.DB, tenantID, scenarioID uint64) error {
|
||||||
|
definitions := []fieldDefinition{
|
||||||
|
{Key: "pet_name", Name: "宠物名称", Type: "text", Required: true},
|
||||||
|
{Key: "pet_type", Name: "宠物种类", Type: "select", Required: true, Options: []string{"犬", "猫", "其他"}},
|
||||||
|
{Key: "pet_age", Name: "年龄(岁)", Type: "number", Validation: map[string]interface{}{"min": 0, "max": 50}},
|
||||||
|
{Key: "pet_weight", Name: "体重(kg)", Type: "number", Validation: map[string]interface{}{"min": 0.01, "max": 200}},
|
||||||
|
{Key: "pet_sex", Name: "性别", Type: "select", Options: []string{"公", "母", "未知"}},
|
||||||
|
{Key: "is_neutered", Name: "是否绝育", Type: "boolean"},
|
||||||
|
{Key: "symptom", Name: "主要症状", Type: "textarea", Required: true},
|
||||||
|
{Key: "symptom_duration", Name: "症状持续时间", Type: "text", Required: true},
|
||||||
|
{Key: "breathing_difficulty", Name: "是否呼吸困难", Type: "boolean", Required: true},
|
||||||
|
{Key: "active_bleeding", Name: "是否持续出血", Type: "boolean", Required: true},
|
||||||
|
{Key: "convulsion", Name: "是否抽搐或意识异常", Type: "boolean", Required: true},
|
||||||
|
{Key: "unable_to_urinate", Name: "是否无法排尿", Type: "boolean", Required: true},
|
||||||
|
{Key: "toxin_exposure", Name: "是否可能误食毒物或异物", Type: "boolean", Required: true},
|
||||||
|
{Key: "has_emergency_sign", Name: "是否存在其他急症表现", Type: "boolean", Required: true},
|
||||||
|
{Key: "appetite", Name: "食欲情况", Type: "select", Options: []string{"正常", "下降", "完全不吃"}},
|
||||||
|
{Key: "spirit_status", Name: "精神状态", Type: "select", Options: []string{"正常", "较差", "嗜睡或无法站立"}},
|
||||||
|
{Key: "vomiting", Name: "是否呕吐", Type: "boolean"},
|
||||||
|
{Key: "diarrhea", Name: "是否腹泻", Type: "boolean"},
|
||||||
|
{Key: "medication_history", Name: "近期用药和保健品", Type: "textarea"},
|
||||||
|
{Key: "allergy_history", Name: "药物过敏史", Type: "textarea"},
|
||||||
|
{Key: "wants_medication", Name: "是否咨询具体用药", Type: "boolean", Required: true},
|
||||||
|
}
|
||||||
|
for index, definition := range definitions {
|
||||||
|
options, _ := json.Marshal(definition.Options)
|
||||||
|
if definition.Options == nil {
|
||||||
|
options = []byte(`[]`)
|
||||||
|
}
|
||||||
|
validation, _ := json.Marshal(definition.Validation)
|
||||||
|
if definition.Validation == nil {
|
||||||
|
validation = []byte(`{}`)
|
||||||
|
}
|
||||||
|
var field model.ScenarioField
|
||||||
|
err := tx.Where("scenario_id = ? AND field_key = ?", scenarioID, definition.Key).Assign(model.ScenarioField{
|
||||||
|
TenantID: tenantID, ScenarioID: scenarioID, FieldName: definition.Name, FieldType: definition.Type,
|
||||||
|
Required: definition.Required, Options: datatypes.JSON(options), Validation: datatypes.JSON(validation), SortOrder: index,
|
||||||
|
}).FirstOrCreate(&field, model.ScenarioField{FieldKey: definition.Key}).Error
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("seed field %s: %w", definition.Key, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func seedKnowledgeCards(tx *gorm.DB, tenantID, userID, scenarioID uint64) (map[string]uint64, error) {
|
||||||
|
definitions := map[string]map[string]interface{}{
|
||||||
|
"急症红旗征象": {
|
||||||
|
"standard_copy": "出现呼吸困难、持续出血、抽搐或意识异常、无法排尿、疑似误食毒物或异物等情况时,应立即建议就近急诊或转诊,并提前联系接诊机构。",
|
||||||
|
"forbidden_copy": "不要承诺在家观察一定安全;不要在线给出能够替代急诊检查的判断。",
|
||||||
|
"risk_note": "急症分支优先级最高,不得因继续询问常规病史而延误就医。",
|
||||||
|
},
|
||||||
|
"问诊用药安全原则": {
|
||||||
|
"standard_copy": "用药需要结合物种、年龄、体重、既往病史、正在使用的药物和必要检查,由宠物医生评估后确定。请勿自行增加剂量、混用药物或使用人用药。",
|
||||||
|
"forbidden_copy": "未完成医生评估前,不给出具体处方药名称、剂量和疗程承诺。",
|
||||||
|
"risk_note": "对乙酰氨基酚、布洛芬等常见人用药可能对宠物造成严重伤害;如已误服,应按急症处理。",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
result := make(map[string]uint64, len(definitions))
|
||||||
|
for title, content := range definitions {
|
||||||
|
var card model.KnowledgeCard
|
||||||
|
err := tx.Where("tenant_id = ? AND scenario_id = ? AND title = ?", tenantID, scenarioID, title).FirstOrCreate(&card, model.KnowledgeCard{
|
||||||
|
TenantID: tenantID, ScenarioID: scenarioID, Title: title, Status: "published", CreatedBy: userID,
|
||||||
|
}).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("seed knowledge card %s: %w", title, err)
|
||||||
|
}
|
||||||
|
if err := tx.Model(&card).Update("status", "published").Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
contentJSON, _ := json.Marshal(content)
|
||||||
|
var version model.KnowledgeCardVersion
|
||||||
|
err = tx.Where("knowledge_card_id = ? AND version = ?", card.ID, 1).Assign(model.KnowledgeCardVersion{
|
||||||
|
TenantID: tenantID, Content: datatypes.JSON(contentJSON), Status: "published",
|
||||||
|
}).FirstOrCreate(&version, model.KnowledgeCardVersion{KnowledgeCardID: card.ID, Version: 1}).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("seed knowledge card version %s: %w", title, err)
|
||||||
|
}
|
||||||
|
result[title] = card.ID
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func seedSOP(tx *gorm.DB, tenantID, userID, scenarioID uint64, knowledgeIDs map[string]uint64) error {
|
||||||
|
var sop model.SOP
|
||||||
|
err := tx.Where("tenant_id = ? AND scenario_id = ? AND name = ?", tenantID, scenarioID, "宠物问诊问药标准 SOP").FirstOrCreate(&sop, model.SOP{
|
||||||
|
TenantID: tenantID, ScenarioID: scenarioID, Name: "宠物问诊问药标准 SOP", CreatedBy: userID,
|
||||||
|
}).Error
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("find or create SOP: %w", err)
|
||||||
|
}
|
||||||
|
if err := tx.Model(&sop).Updates(map[string]interface{}{
|
||||||
|
"description": "从基本信息和主诉采集开始,优先筛查急症,再补充病史并给出安全的下一步指引。",
|
||||||
|
"status": "published",
|
||||||
|
}).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var seededPublished int64
|
||||||
|
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"},
|
||||||
|
).Count(&seededPublished).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if seededPublished == 3 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var version model.SOPVersion
|
||||||
|
err = tx.Where("sop_id = ? AND tenant_id = ? AND status = ?", sop.ID, tenantID, "draft").Order("version DESC").First(&version).Error
|
||||||
|
if err == gorm.ErrRecordNotFound {
|
||||||
|
var maxVersion int
|
||||||
|
if err := tx.Model(&model.SOPVersion{}).Where("sop_id = ?", sop.ID).Select("COALESCE(MAX(version), 0)").Scan(&maxVersion).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
version = model.SOPVersion{TenantID: tenantID, SOPID: sop.ID, Version: maxVersion + 1, Status: "draft", StartNodeKey: "start", CreatedBy: userID}
|
||||||
|
if err := tx.Create(&version).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
} else if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
nodes := petNodes(knowledgeIDs["问诊用药安全原则"])
|
||||||
|
edges := petEdges()
|
||||||
|
if err := tx.Where("sop_version_id = ?", version.ID).Delete(&model.SOPEdge{}).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := tx.Where("sop_version_id = ?", version.ID).Delete(&model.SOPNode{}).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for index, definition := range nodes {
|
||||||
|
configJSON, _ := json.Marshal(definition.Config)
|
||||||
|
node := model.SOPNode{
|
||||||
|
TenantID: tenantID, SOPVersionID: version.ID, NodeKey: definition.Key, Type: definition.Type,
|
||||||
|
Title: definition.Title, Content: definition.Content, Config: datatypes.JSON(configJSON), PositionX: 0, PositionY: index * 120,
|
||||||
|
}
|
||||||
|
if err := tx.Create(&node).Error; err != nil {
|
||||||
|
return fmt.Errorf("create node %s: %w", definition.Key, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, definition := range edges {
|
||||||
|
conditionJSON, _ := json.Marshal(definition.Condition)
|
||||||
|
edge := model.SOPEdge{
|
||||||
|
TenantID: tenantID, SOPVersionID: version.ID, SourceNodeKey: definition.Source, TargetNodeKey: definition.Target,
|
||||||
|
Condition: datatypes.JSON(conditionJSON), Priority: definition.Priority,
|
||||||
|
}
|
||||||
|
if err := tx.Create(&edge).Error; err != nil {
|
||||||
|
return fmt.Errorf("create edge %s -> %s: %w", definition.Source, definition.Target, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
if err := tx.Model(&model.SOPVersion{}).Where("sop_id = ? AND id <> ? AND status = ?", sop.ID, version.ID, "published").Update("status", "superseded").Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := tx.Model(&version).Updates(map[string]interface{}{
|
||||||
|
"status": "published", "start_node_key": "start", "published_at": &now, "reviewed_by": userID,
|
||||||
|
}).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
payload, _ := json.Marshal(map[string]interface{}{"scenario": "宠物医生问诊问药", "version": version.Version})
|
||||||
|
return tx.Create(&model.AuditLog{
|
||||||
|
TenantID: tenantID, UserID: userID, Action: "seed", Resource: "sop", ResourceID: sop.ID, Payload: datatypes.JSON(payload),
|
||||||
|
}).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func petNodes(knowledgeCardID uint64) []nodeDefinition {
|
||||||
|
return []nodeDefinition{
|
||||||
|
{Key: "start", Type: "start", Title: "开始", Config: map[string]interface{}{"seed_key": "pet-doctor-v1"}},
|
||||||
|
{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: "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_check", Type: "condition", Title: "判断是否需要立即转诊", Content: "系统根据急症筛查结果自动分流。", Config: map[string]interface{}{}},
|
||||||
|
{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: "medication_intent", Type: "question", Title: "确认用药诉求", Content: "请确认宠物主人是否正在咨询具体药物、剂量或疗程。", Config: map[string]interface{}{"field_key": "wants_medication", "required": true}},
|
||||||
|
{Key: "medication_safety", Type: "knowledge", Title: "说明用药安全原则", Content: "用药需要结合物种、年龄、体重、既往病史和必要检查,由宠物医生评估后确定。请勿自行使用人用药、增加剂量或混用药物。", Config: map[string]interface{}{"knowledge_card_id": knowledgeCardID}},
|
||||||
|
{Key: "assessment", Type: "message", Title: "提交医生评估", Content: "信息已记录。请由宠物医生结合体格检查和必要检验判断病因及治疗方案;如症状加重或出现新的急症表现,应立即就医。", Config: map[string]interface{}{}},
|
||||||
|
{Key: "finish", Type: "finish", Title: "完成问诊记录", Content: "本次初步问诊信息已完整记录,请按医生建议安排复诊、检查或治疗。", Config: map[string]interface{}{}},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func petEdges() []edgeDefinition {
|
||||||
|
emergencyRules := []interface{}{
|
||||||
|
map[string]interface{}{"field": "breathing_difficulty", "operator": "equals", "value": true},
|
||||||
|
map[string]interface{}{"field": "active_bleeding", "operator": "equals", "value": true},
|
||||||
|
map[string]interface{}{"field": "convulsion", "operator": "equals", "value": true},
|
||||||
|
map[string]interface{}{"field": "unable_to_urinate", "operator": "equals", "value": true},
|
||||||
|
map[string]interface{}{"field": "toxin_exposure", "operator": "equals", "value": true},
|
||||||
|
map[string]interface{}{"field": "has_emergency_sign", "operator": "equals", "value": true},
|
||||||
|
}
|
||||||
|
return []edgeDefinition{
|
||||||
|
{Source: "start", Target: "opening", Condition: map[string]interface{}{}},
|
||||||
|
{Source: "opening", Target: "basic_info", Condition: map[string]interface{}{}},
|
||||||
|
{Source: "basic_info", Target: "chief_complaint", Condition: map[string]interface{}{}},
|
||||||
|
{Source: "chief_complaint", Target: "emergency_screen", Condition: map[string]interface{}{}},
|
||||||
|
{Source: "emergency_screen", Target: "emergency_check", Condition: map[string]interface{}{}},
|
||||||
|
{Source: "emergency_check", Target: "emergency_escalate", Condition: map[string]interface{}{"any": emergencyRules}, Priority: 0},
|
||||||
|
{Source: "emergency_check", Target: "history_collection", Condition: map[string]interface{}{}, Priority: 100},
|
||||||
|
{Source: "history_collection", Target: "medication_intent", Condition: map[string]interface{}{}},
|
||||||
|
{Source: "medication_intent", Target: "medication_safety", Condition: map[string]interface{}{"field": "wants_medication", "operator": "equals", "value": true}, Priority: 0},
|
||||||
|
{Source: "medication_intent", Target: "assessment", Condition: map[string]interface{}{}, Priority: 100},
|
||||||
|
{Source: "medication_safety", Target: "assessment", Condition: map[string]interface{}{}},
|
||||||
|
{Source: "assessment", Target: "finish", Condition: map[string]interface{}{}},
|
||||||
|
}
|
||||||
|
}
|
||||||
3
web/dist/assets/SOPEditorView-2uB6hvkh.js
vendored
Normal file
3
web/dist/assets/SOPEditorView-2uB6hvkh.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
web/dist/assets/SOPEditorView-C7DDP1Zg.css
vendored
Normal file
1
web/dist/assets/SOPEditorView-C7DDP1Zg.css
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
.editor-page[data-v-a5b6c482]{background:#eef1ef;min-height:calc(100vh - 62px)}.editor-header[data-v-a5b6c482]{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-a5b6c482]{align-items:center;gap:8px;display:flex}.editor-title>div[data-v-a5b6c482]{flex-direction:column;display:flex}.editor-title b[data-v-a5b6c482]{font-size:15px}.editor-title span[data-v-a5b6c482]{color:var(--muted);margin-top:3px;font-size:11px}.editor-workbench[data-v-a5b6c482]{grid-template-columns:280px minmax(0,1fr);gap:16px;max-width:1500px;margin:0 auto;padding:18px;display:grid}.node-rail[data-v-a5b6c482]{align-self:start;position:sticky;top:146px;overflow:hidden}.rail-title[data-v-a5b6c482]{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-a5b6c482]{padding:8px}.node-list button[data-v-a5b6c482]{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-a5b6c482]:hover{background:#f5f8f6}.node-list button.active[data-v-a5b6c482]{background:#edf6f2;border-color:#b9d8cc}.node-index[data-v-a5b6c482]{color:#88958f;font-family:monospace;font-size:11px}.node-meta[data-v-a5b6c482]{flex-direction:column;min-width:0;display:flex}.node-meta b[data-v-a5b6c482]{text-overflow:ellipsis;white-space:nowrap;font-size:13px;overflow:hidden}.node-meta small[data-v-a5b6c482]{color:var(--muted);margin-top:3px;font-size:11px}.node-move[data-v-a5b6c482]{color:#87938e;gap:4px;display:flex}.node-move[data-v-a5b6c482]>:hover{color:var(--green)}.node-editor[data-v-a5b6c482]{align-self:start;min-height:600px;padding:24px}.node-editor-head[data-v-a5b6c482]{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-a5b6c482]{margin:6px 0 0;font-family:Noto Serif SC,serif;font-size:22px}.node-type-mark[data-v-a5b6c482]{color:var(--green);font-size:10px;font-weight:750}.property-form[data-v-a5b6c482]{padding-top:22px}.property-grid[data-v-a5b6c482]{grid-template-columns:1fr 1fr;gap:16px;display:grid}.transition-head[data-v-a5b6c482]{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-a5b6c482]{flex-direction:column;display:flex}.transition-head span[data-v-a5b6c482]{color:var(--muted);margin-top:4px;font-size:11px}.transition-row[data-v-a5b6c482]{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-a5b6c482]{background:var(--green);width:16px;height:1px}.route-target[data-v-a5b6c482]{font-weight:600}.route-empty[data-v-a5b6c482]{color:var(--muted);padding:20px 0;font-size:12px}.edge-builder[data-v-a5b6c482]{grid-template-columns:1.1fr 1fr 120px 1fr auto;gap:8px;padding-top:16px;display:grid}.editor-empty[data-v-a5b6c482]{place-items:center;min-height:400px;display:grid}@media (width<=1050px){.edge-builder[data-v-a5b6c482]{grid-template-columns:1fr 1fr}.editor-workbench[data-v-a5b6c482]{grid-template-columns:230px minmax(0,1fr)}}@media (width<=760px){.editor-header[data-v-a5b6c482]{flex-direction:column;align-items:flex-start;padding:12px;top:62px}.editor-workbench[data-v-a5b6c482]{grid-template-columns:1fr;padding:10px}.node-rail[data-v-a5b6c482]{position:static}.property-grid[data-v-a5b6c482],.edge-builder[data-v-a5b6c482]{grid-template-columns:1fr}.node-editor[data-v-a5b6c482]{padding:16px}.transition-head[data-v-a5b6c482]{margin:8px -16px 0;padding:16px}}
|
||||||
3
web/dist/assets/SOPEditorView-CmiIrIPT.js
vendored
3
web/dist/assets/SOPEditorView-CmiIrIPT.js
vendored
File diff suppressed because one or more lines are too long
1
web/dist/assets/SOPEditorView-DdGSx6p0.css
vendored
1
web/dist/assets/SOPEditorView-DdGSx6p0.css
vendored
@@ -1 +0,0 @@
|
|||||||
.editor-page[data-v-06435891]{background:#eef1ef;min-height:calc(100vh - 62px)}.editor-header[data-v-06435891]{border-bottom:1px solid var(--line);z-index:10;background:#fff;justify-content:space-between;align-items:center;gap:16px;min-height:66px;padding:10px 24px;display:flex;position:sticky;top:62px}.editor-title[data-v-06435891]{align-items:center;gap:8px;display:flex}.editor-title>div[data-v-06435891]{flex-direction:column;display:flex}.editor-title b[data-v-06435891]{font-size:15px}.editor-title span[data-v-06435891]{color:var(--muted);margin-top:3px;font-size:11px}.editor-workbench[data-v-06435891]{grid-template-columns:280px minmax(0,1fr);gap:16px;max-width:1500px;margin:0 auto;padding:18px;display:grid}.node-rail[data-v-06435891]{align-self:start;position:sticky;top:146px;overflow:hidden}.rail-title[data-v-06435891]{border-bottom:1px solid var(--line);justify-content:space-between;align-items:center;height:50px;padding:0 14px;font-weight:650;display:flex}.node-list[data-v-06435891]{padding:8px}.node-list button[data-v-06435891]{text-align:left;cursor:pointer;background:0 0;border:1px solid #0000;border-radius:4px;grid-template-columns:34px 1fr auto;align-items:center;gap:9px;width:100%;min-height:58px;padding:7px 8px;display:grid}.node-list button[data-v-06435891]:hover{background:#f5f8f6}.node-list button.active[data-v-06435891]{background:#edf6f2;border-color:#b9d8cc}.node-index[data-v-06435891]{color:#88958f;font-family:monospace;font-size:11px}.node-meta[data-v-06435891]{flex-direction:column;min-width:0;display:flex}.node-meta b[data-v-06435891]{text-overflow:ellipsis;white-space:nowrap;font-size:13px;overflow:hidden}.node-meta small[data-v-06435891]{color:var(--muted);margin-top:3px;font-size:11px}.node-move[data-v-06435891]{color:#87938e;gap:4px;display:flex}.node-move[data-v-06435891]>:hover{color:var(--green)}.node-editor[data-v-06435891]{align-self:start;min-height:600px;padding:24px}.node-editor-head[data-v-06435891]{border-bottom:1px solid var(--line);justify-content:space-between;align-items:flex-start;padding-bottom:18px;display:flex}.node-editor-head h2[data-v-06435891]{margin:6px 0 0;font-family:Noto Serif SC,serif;font-size:22px}.node-type-mark[data-v-06435891]{color:var(--green);font-size:10px;font-weight:750}.property-form[data-v-06435891]{padding-top:22px}.property-grid[data-v-06435891]{grid-template-columns:1fr 1fr;gap:16px;display:grid}.transition-head[data-v-06435891]{border-top:1px solid var(--line);justify-content:space-between;margin:8px -24px 0;padding:18px 24px 10px;display:flex}.transition-head>div[data-v-06435891]{flex-direction:column;display:flex}.transition-head span[data-v-06435891]{color:var(--muted);margin-top:4px;font-size:11px}.transition-row[data-v-06435891]{border-bottom:1px solid #edf0ee;grid-template-columns:20px minmax(120px,1fr) auto 36px;align-items:center;gap:10px;min-height:48px;display:grid}.route-line[data-v-06435891]{background:var(--green);width:16px;height:1px}.route-target[data-v-06435891]{font-weight:600}.route-empty[data-v-06435891]{color:var(--muted);padding:20px 0;font-size:12px}.edge-builder[data-v-06435891]{grid-template-columns:1.1fr 1fr 120px 1fr auto;gap:8px;padding-top:16px;display:grid}.editor-empty[data-v-06435891]{place-items:center;min-height:400px;display:grid}@media (width<=1050px){.edge-builder[data-v-06435891]{grid-template-columns:1fr 1fr}.editor-workbench[data-v-06435891]{grid-template-columns:230px minmax(0,1fr)}}@media (width<=760px){.editor-header[data-v-06435891]{flex-direction:column;align-items:flex-start;padding:12px;top:62px}.editor-workbench[data-v-06435891]{grid-template-columns:1fr;padding:10px}.node-rail[data-v-06435891]{position:static}.property-grid[data-v-06435891],.edge-builder[data-v-06435891]{grid-template-columns:1fr}.node-editor[data-v-06435891]{padding:16px}.transition-head[data-v-06435891]{margin:8px -16px 0;padding:16px}}
|
|
||||||
File diff suppressed because one or more lines are too long
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-DquTypIs.js"></script>
|
<script type="module" crossorigin src="/assets/index-CsZhTuSf.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">
|
||||||
|
|||||||
@@ -4,16 +4,19 @@ import { useRoute, useRouter } from 'vue-router'
|
|||||||
import { ArrowDownOutlined, ArrowLeftOutlined, ArrowUpOutlined, DeleteOutlined, PlusOutlined, SaveOutlined, SendOutlined } from '@ant-design/icons-vue'
|
import { ArrowDownOutlined, ArrowLeftOutlined, ArrowUpOutlined, DeleteOutlined, PlusOutlined, 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 type { SOP, SOPEdge, SOPNode, SOPVersion } from '@/types'
|
import type { ScenarioField, SOP, SOPEdge, SOPNode, SOPVersion } from '@/types'
|
||||||
|
|
||||||
|
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 loading = ref(true); const saving = ref(false)
|
const loading = ref(true); const saving = 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 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 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 reviewing = computed(() => version.value?.status === 'reviewing')
|
const reviewing = computed(() => version.value?.status === 'reviewing')
|
||||||
const nodeTypes = [{value:'message',label:'标准话术'},{value:'question',label:'单项提问'},{value:'choice',label:'选择判断'},{value:'condition',label:'条件节点'},{value:'knowledge',label:'知识卡'},{value:'escalate',label:'转人工/转诊'},{value:'finish',label:'结束'}]
|
const 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: '' })
|
const edgeDraft = reactive({ target_node_key: '', field: '', operator: 'equals', value: '' })
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
@@ -21,6 +24,11 @@ async function load() {
|
|||||||
try {
|
try {
|
||||||
const data = await api.get<{ sop:SOP; version:SOPVersion; nodes:SOPNode[]; edges:SOPEdge[] }>(`/sops/${id}`)
|
const data = await api.get<{ sop:SOP; version:SOPVersion; nodes:SOPNode[]; edges:SOPEdge[] }>(`/sops/${id}`)
|
||||||
sop.value=data.sop; version.value=data.version; nodes.value=data.nodes.map(n=>({...n,config:n.config||{}})); edges.value=data.edges.map(e=>({...e,condition:e.condition||{}})); selectedKey.value ||= nodes.value[0]?.node_key || ''
|
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] = await Promise.all([
|
||||||
|
api.get<{ fields:ScenarioField[] }>(`/scenarios/${data.sop.scenario_id}`),
|
||||||
|
api.get<{ items:KnowledgeCard[] }>('/knowledge-cards'),
|
||||||
|
])
|
||||||
|
fields.value=scenarioData.fields; knowledgeCards.value=cardData.items.filter(card=>card.scenario_id===data.sop.scenario_id)
|
||||||
} catch(error) { message.error(apiMessage(error)) } finally { loading.value=false }
|
} catch(error) { message.error(apiMessage(error)) } finally { loading.value=false }
|
||||||
}
|
}
|
||||||
function addNode() {
|
function addNode() {
|
||||||
@@ -35,7 +43,7 @@ function outgoing(key:string){return edges.value.filter(e=>e.source_node_key===k
|
|||||||
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 addEdge(){if(!selected.value||!edgeDraft.target_node_key)return;const condition=edgeDraft.field?{field:edgeDraft.field,operator:edgeDraft.operator,value:parseValue(edgeDraft.value)}:{};edges.value.push({id:0,created_at:'',updated_at:'',source_node_key:selected.value.node_key,target_node_key:edgeDraft.target_node_key,condition,priority:outgoing(selected.value.node_key).length});Object.assign(edgeDraft,{target_node_key:'',field:'',operator:'equals',value:''})}
|
||||||
function removeEdge(edge:SOPEdge){edges.value=edges.value.filter(e=>e!==edge)}
|
function removeEdge(edge:SOPEdge){edges.value=edges.value.filter(e=>e!==edge)}
|
||||||
function parseValue(value:string){if(value==='true')return true;if(value==='false')return false;if(value!==''&&!Number.isNaN(Number(value)))return Number(value);return value}
|
function parseValue(value:string){if(value==='true')return true;if(value==='false')return false;if(value!==''&&!Number.isNaN(Number(value)))return Number(value);return value}
|
||||||
function conditionText(condition:Record<string,any>){if(!condition||!condition.field)return '默认路径';const names:Record<string,string>={equals:'等于',not_equals:'不等于',contains:'包含',greater_than:'大于',less_than:'小于',exists:'已填写',not_exists:'未填写'};return `${condition.field} ${names[condition.operator]||condition.operator} ${condition.value ?? ''}`}
|
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 ?? ''}`}
|
||||||
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(){saving.value=true;try{await api.put(`/sops/${id}/draft`,{start_node_key:version.value?.start_node_key||'start',nodes:nodes.value.map(({node_key,type,title,content,config,position_x,position_y})=>({node_key,type,title,content,config,position_x,position_y})),edges:edges.value.map(({source_node_key,target_node_key,condition,priority})=>({source_node_key,target_node_key,condition,priority}))});message.success('草稿已保存');await load()}catch(error){message.error(apiMessage(error))}finally{saving.value=false}}
|
||||||
async function validate(){try{const data=await api.post<{valid:boolean;problems:string[]}>(`/sops/${id}/validate`);data.valid?message.success('流程校验通过'):Modal.warning({title:'流程还不能发布',content:data.problems.join(';')})}catch(error){message.error(apiMessage(error))}}
|
async function validate(){try{const data=await api.post<{valid:boolean;problems:string[]}>(`/sops/${id}/validate`);data.valid?message.success('流程校验通过'):Modal.warning({title:'流程还不能发布',content:data.problems.join(';')})}catch(error){message.error(apiMessage(error))}}
|
||||||
async function submitReview(){try{await save();await api.post(`/sops/${id}/submit-review`);message.success('SOP 已提交审核');await load()}catch(error){message.error(apiMessage(error))}}
|
async function submitReview(){try{await save();await api.post(`/sops/${id}/submit-review`);message.success('SOP 已提交审核');await load()}catch(error){message.error(apiMessage(error))}}
|
||||||
@@ -64,8 +72,10 @@ onMounted(load)
|
|||||||
<a-form layout="vertical" class="property-form">
|
<a-form layout="vertical" class="property-form">
|
||||||
<div class="property-grid"><a-form-item label="节点名称"><a-input v-model:value="selected.title" :disabled="!editable" /></a-form-item><a-form-item label="节点类型"><a-select v-model:value="selected.type" :disabled="!editable||selected.type==='start'" :options="nodeTypes" /></a-form-item></div>
|
<div class="property-grid"><a-form-item label="节点名称"><a-input v-model:value="selected.title" :disabled="!editable" /></a-form-item><a-form-item label="节点类型"><a-select v-model:value="selected.type" :disabled="!editable||selected.type==='start'" :options="nodeTypes" /></a-form-item></div>
|
||||||
<a-form-item label="标准话术 / 操作提示"><a-textarea v-model:value="selected.content" :disabled="!editable" :rows="5" placeholder="执行到此节点时展示给一线人员的内容" /></a-form-item>
|
<a-form-item label="标准话术 / 操作提示"><a-textarea v-model:value="selected.content" :disabled="!editable" :rows="5" placeholder="执行到此节点时展示给一线人员的内容" /></a-form-item>
|
||||||
<template v-if="['question','choice'].includes(selected.type)"><div class="property-grid"><a-form-item label="写入字段标识"><a-input v-model:value="selected.config.field_key" :disabled="!editable" placeholder="例如 pet_weight" /></a-form-item><a-form-item label="是否必填"><a-switch v-model:checked="selected.config.required" :disabled="!editable" /></a-form-item></div></template>
|
<template v-if="['question','choice'].includes(selected.type)"><div class="property-grid"><a-form-item label="写入场景字段"><a-select v-model:value="selected.config.field_key" :disabled="!editable" show-search option-filter-prop="label" placeholder="选择需要采集的字段" :options="fields.map(field=>({value:field.field_key,label:`${field.field_name} (${field.field_key})`}))" /></a-form-item><a-form-item label="是否必填"><a-switch v-model:checked="selected.config.required" :disabled="!editable" /></a-form-item></div></template>
|
||||||
|
<a-form-item v-if="selected.type==='form'" label="表单采集字段"><a-select v-model:value="selected.config.field_keys" mode="multiple" :disabled="!editable" show-search option-filter-prop="label" placeholder="选择本步骤需要采集的字段" :options="fields.map(field=>({value:field.field_key,label:`${field.field_name} (${field.field_key})`}))" /></a-form-item>
|
||||||
<a-form-item v-if="selected.type==='choice'" label="可选项(每行一个)"><a-textarea :value="(selected.config.options||[]).join('\n')" :disabled="!editable" :rows="4" @change="selected.config.options=($event.target as HTMLTextAreaElement).value.split('\n').filter(Boolean)" /></a-form-item>
|
<a-form-item v-if="selected.type==='choice'" label="可选项(每行一个)"><a-textarea :value="(selected.config.options||[]).join('\n')" :disabled="!editable" :rows="4" @change="selected.config.options=($event.target as HTMLTextAreaElement).value.split('\n').filter(Boolean)" /></a-form-item>
|
||||||
|
<a-form-item v-if="selected.type==='knowledge'" label="关联知识卡"><a-select v-model:value="selected.config.knowledge_card_id" :disabled="!editable" 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="editable" type="text" danger aria-label="删除路径" @click="removeEdge(edge)"><DeleteOutlined /></a-button></div><div v-if="!outgoing(selectedKey).length" class="route-empty">还没有下一步路径</div></div>
|
<div class="transition-list"><div v-for="edge in outgoing(selectedKey)" :key="`${edge.source_node_key}-${edge.target_node_key}-${edge.priority}`" class="transition-row"><span class="route-line"></span><span class="route-target">{{ nodes.find(n=>n.node_key===edge.target_node_key)?.title || edge.target_node_key }}</span><a-tag>{{ conditionText(edge.condition) }}</a-tag><a-button v-if="editable" type="text" danger aria-label="删除路径" @click="removeEdge(edge)"><DeleteOutlined /></a-button></div><div v-if="!outgoing(selectedKey).length" class="route-empty">还没有下一步路径</div></div>
|
||||||
|
|||||||
Reference in New Issue
Block a user