feat: complete SOP execution workflow

This commit is contained in:
Eric 1549169735@qq.com
2026-08-09 00:29:26 +08:00
parent e32025d2ca
commit c5ab886b70
63 changed files with 1229 additions and 215 deletions

8
.dockerignore Normal file
View File

@@ -0,0 +1,8 @@
.git
.gitignore
.env
bin
web/dist
web/node_modules
*.log
.DS_Store

10
.env.example Normal file
View File

@@ -0,0 +1,10 @@
APP_HTTP_PORT=18090
GOPROXY=https://goproxy.cn,direct
APP_AUTH_JWT_SECRET=replace-with-at-least-32-random-characters
APP_SEED_ADMIN_USERNAME=admin
APP_SEED_ADMIN_PASSWORD=replace-with-a-strong-initial-password
APP_SEED_ADMIN_DISPLAY_NAME=平台管理员
MYSQL_DATABASE=iqudo_top1
MYSQL_USER=iqudo
MYSQL_PASSWORD=replace-with-a-strong-database-password
MYSQL_ROOT_PASSWORD=replace-with-a-different-root-password

30
Dockerfile Normal file
View File

@@ -0,0 +1,30 @@
FROM node:22-alpine AS web-builder
WORKDIR /src/web
COPY web/package.json web/package-lock.json ./
RUN npm ci
COPY web/ ./
RUN npm run build
FROM golang:1.24-alpine AS go-builder
ARG GOPROXY=https://goproxy.cn,direct
WORKDIR /src
COPY go.mod go.sum ./
RUN GOPROXY=${GOPROXY} go mod download
COPY . ./
COPY --from=web-builder /src/web/dist ./web/dist
RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o /out/iqudo-top1 .
FROM alpine:3.22
RUN apk add --no-cache ca-certificates tzdata \
&& addgroup -S app \
&& adduser -S -G app app
WORKDIR /app
COPY --from=go-builder /out/iqudo-top1 /app/iqudo-top1
COPY configs/config.example.yml /app/configs/config.yml
COPY configs/config.prod.yml /app/configs/config.prod.yml
USER app
EXPOSE 8080
ENTRYPOINT ["/app/iqudo-top1", "-env", "prod", "-config-dir", "/app/configs"]

View File

@@ -1,6 +1,6 @@
WEB_DIR := web WEB_DIR := web
.PHONY: web-install web-build build test vet check seed-pet-doctor .PHONY: web-install web-build build test vet check seed-pet-doctor docker-config docker-up docker-down docker-logs
web-install: web-install:
cd $(WEB_DIR) && npm install cd $(WEB_DIR) && npm install
@@ -21,3 +21,15 @@ check: test vet
seed-pet-doctor: seed-pet-doctor:
go run ./scripts/seed-pet-doctor go run ./scripts/seed-pet-doctor
docker-config:
docker compose config --quiet
docker-up:
docker compose up --build -d
docker-down:
docker compose down
docker-logs:
docker compose logs -f app nginx

View File

@@ -13,6 +13,21 @@ make build
默认配置位于 `configs/config.yml`,可以通过环境变量覆盖。 默认配置位于 `configs/config.yml`,可以通过环境变量覆盖。
## Docker Compose 运行
Compose 会启动 MySQL、应用服务和 Nginx默认通过 `18090` 端口访问:
```bash
cp .env.example .env
# 修改 .env 中的数据库密码、JWT 密钥和初始管理员密码
make docker-config
make docker-up
```
查看服务日志使用 `make docker-logs`,停止服务使用 `make docker-down`。MySQL 数据保存在 `mysql-data` 命名卷中,停止服务不会删除数据。
镜像构建默认使用 `.env` 中的 `GOPROXY`。当前模板使用适合国内网络的代理;其他网络环境可以改为 `https://proxy.golang.org,direct`
## 宠物医生问诊问药示范场景 ## 宠物医生问诊问药示范场景
数据库迁移和管理员初始化完成后执行: 数据库迁移和管理员初始化完成后执行:
@@ -24,8 +39,8 @@ make seed-pet-doctor
该命令会幂等地创建或完善以下配置: 该命令会幂等地创建或完善以下配置:
- 宠物医生问诊问药场景 - 宠物医生问诊问药场景
- 21 个动态问诊字段 - 47 个动态问诊字段,覆盖咨询背景、宠物档案、主诉、生命状态、既往史和用药详情
- 急症红旗征象和问诊用药安全原则知识卡 - 线上问诊边界、急症红旗、用药安全、观察与复诊 4 张知识卡
- 包含普通问诊急症转诊分支的已发布 SOP - 包含普通问诊急症转诊、常规用药咨询和用药风险转诊分支的已发布 SOP
示范场景是配置数据,通用流程引擎不包含宠物行业专用判断。 示范场景是配置数据,通用流程引擎不包含宠物行业专用判断。

23
deploy/nginx.conf Normal file
View File

@@ -0,0 +1,23 @@
upstream iqudo_app {
server app:8080;
keepalive 32;
}
server {
listen 80;
server_name _;
client_max_body_size 1m;
location / {
proxy_pass http://iqudo_app;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Request-ID $request_id;
proxy_connect_timeout 5s;
proxy_read_timeout 30s;
}
}

65
docker-compose.yml Normal file
View File

@@ -0,0 +1,65 @@
services:
mysql:
image: mysql:8.4
restart: unless-stopped
environment:
MYSQL_DATABASE: ${MYSQL_DATABASE:-iqudo_top1}
MYSQL_USER: ${MYSQL_USER:-iqudo}
MYSQL_PASSWORD: ${MYSQL_PASSWORD:?set MYSQL_PASSWORD in .env}
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD:?set MYSQL_ROOT_PASSWORD in .env}
TZ: Asia/Shanghai
command: ["--character-set-server=utf8mb4", "--collation-server=utf8mb4_unicode_ci"]
volumes:
- mysql-data:/var/lib/mysql
healthcheck:
test: ["CMD-SHELL", "mysqladmin ping -h 127.0.0.1 -uroot -p$$MYSQL_ROOT_PASSWORD --silent"]
interval: 5s
timeout: 3s
retries: 20
start_period: 20s
app:
build:
context: .
args:
GOPROXY: ${GOPROXY:-https://goproxy.cn,direct}
restart: unless-stopped
environment:
APP_ENV: prod
APP_SERVER_HOST: 0.0.0.0
APP_SERVER_PORT: 8080
APP_DATABASE_HOST: mysql
APP_DATABASE_PORT: 3306
APP_DATABASE_NAME: ${MYSQL_DATABASE:-iqudo_top1}
APP_DATABASE_USER: ${MYSQL_USER:-iqudo}
APP_DATABASE_PASSWORD: ${MYSQL_PASSWORD:?set MYSQL_PASSWORD in .env}
APP_AUTH_JWT_SECRET: ${APP_AUTH_JWT_SECRET:?set APP_AUTH_JWT_SECRET in .env}
APP_SEED_ADMIN_USERNAME: ${APP_SEED_ADMIN_USERNAME:-admin}
APP_SEED_ADMIN_PASSWORD: ${APP_SEED_ADMIN_PASSWORD:?set APP_SEED_ADMIN_PASSWORD in .env}
APP_SEED_ADMIN_DISPLAY_NAME: ${APP_SEED_ADMIN_DISPLAY_NAME:-平台管理员}
TZ: Asia/Shanghai
depends_on:
mysql:
condition: service_healthy
expose:
- "8080"
healthcheck:
test: ["CMD", "wget", "-q", "-O", "-", "http://127.0.0.1:8080/api/v1/health"]
interval: 10s
timeout: 3s
retries: 10
start_period: 10s
nginx:
image: nginx:1.27-alpine
restart: unless-stopped
ports:
- "${APP_HTTP_PORT:-18090}:80"
volumes:
- ./deploy/nginx.conf:/etc/nginx/conf.d/default.conf:ro
depends_on:
app:
condition: service_healthy
volumes:
mysql-data:

View File

@@ -5,6 +5,7 @@ import (
"git.iwork-ai.com/xdc/iqudo-top1/internal/auth" "git.iwork-ai.com/xdc/iqudo-top1/internal/auth"
"git.iwork-ai.com/xdc/iqudo-top1/internal/model" "git.iwork-ai.com/xdc/iqudo-top1/internal/model"
"go.uber.org/zap"
"gorm.io/datatypes" "gorm.io/datatypes"
"gorm.io/gorm" "gorm.io/gorm"
) )
@@ -14,5 +15,21 @@ func Record(db *gorm.DB, principal auth.Principal, action, resource string, reso
if err != nil { if err != nil {
return err return err
} }
return db.Create(&model.AuditLog{TenantID: principal.TenantID, UserID: principal.UserID, Action: action, Resource: resource, ResourceID: resourceID, Payload: datatypes.JSON(data)}).Error fields := []zap.Field{
zap.Uint64("tenant_id", principal.TenantID),
zap.Uint64("user_id", principal.UserID),
zap.String("action", action),
zap.String("resource", resource),
zap.Uint64("resource_id", resourceID),
}
if err := db.Create(&model.AuditLog{TenantID: principal.TenantID, UserID: principal.UserID, Action: action, Resource: resource, ResourceID: resourceID, Payload: datatypes.JSON(data)}).Error; err != nil {
zap.L().Error("persist business event", append(fields, zap.Error(err))...)
return err
}
logBusinessEvent(fields)
return nil
}
func logBusinessEvent(fields []zap.Field) {
zap.L().Info("business event", fields...)
} }

View File

@@ -0,0 +1,34 @@
package audit
import (
"testing"
"go.uber.org/zap"
"go.uber.org/zap/zapcore"
"go.uber.org/zap/zaptest/observer"
)
func TestLogBusinessEventIncludesRequiredFields(t *testing.T) {
core, logs := observer.New(zapcore.InfoLevel)
undo := zap.ReplaceGlobals(zap.New(core))
defer undo()
logBusinessEvent([]zap.Field{
zap.Uint64("tenant_id", 1),
zap.Uint64("user_id", 2),
zap.String("action", "publish"),
zap.String("resource", "sop"),
zap.Uint64("resource_id", 3),
})
entries := logs.All()
if len(entries) != 1 || entries[0].Message != "business event" {
t.Fatalf("entries = %+v, want one business event", entries)
}
fields := entries[0].ContextMap()
for _, key := range []string{"tenant_id", "user_id", "action", "resource", "resource_id"} {
if _, ok := fields[key]; !ok {
t.Fatalf("required field %q is missing from business event", key)
}
}
}

View File

@@ -78,7 +78,7 @@ func (s *Service) Refresh(rawToken string) (TokenPair, error) {
return TokenPair{}, ErrInvalidCredentials return TokenPair{}, ErrInvalidCredentials
} }
var user model.User var user model.User
if err := s.db.First(&user, stored.UserID).Error; err != nil { if err := s.db.Where("id = ? AND status = ?", stored.UserID, "active").First(&user).Error; err != nil {
return TokenPair{}, err return TokenPair{}, err
} }
principal, err := s.principalForTenant(user, stored.TenantID) principal, err := s.principalForTenant(user, stored.TenantID)

View File

@@ -76,6 +76,7 @@ func New(db *gorm.DB, authService *auth.Service, frontend fs.FS, log *zap.Logger
protected.GET("/published-sops", middleware.RequirePermission("sop.execute"), runHandler.PublishedSOPs) protected.GET("/published-sops", middleware.RequirePermission("sop.execute"), runHandler.PublishedSOPs)
protected.GET("/runs", middleware.RequireAnyPermission("runs.view_all", "runs.view_own"), runHandler.List) protected.GET("/runs", middleware.RequireAnyPermission("runs.view_all", "runs.view_own"), runHandler.List)
protected.GET("/runs/options", middleware.RequireAnyPermission("runs.view_all", "runs.view_own"), runHandler.Options)
protected.POST("/runs", middleware.RequirePermission("sop.execute"), runHandler.Start) protected.POST("/runs", middleware.RequirePermission("sop.execute"), runHandler.Start)
protected.GET("/runs/:id", middleware.RequireAnyPermission("runs.view_all", "runs.view_own"), runHandler.Get) protected.GET("/runs/:id", middleware.RequireAnyPermission("runs.view_all", "runs.view_own"), runHandler.Get)
protected.GET("/runs/:id/detail", middleware.RequireAnyPermission("runs.view_all", "runs.view_own"), runHandler.Detail) protected.GET("/runs/:id/detail", middleware.RequireAnyPermission("runs.view_all", "runs.view_own"), runHandler.Detail)

View File

@@ -113,10 +113,7 @@ func (h *Handler) Update(c *gin.Context) {
return return
} }
err = h.db.Transaction(func(tx *gorm.DB) error { err = h.db.Transaction(func(tx *gorm.DB) error {
if err := tx.Model(&membership).Updates(map[string]interface{}{"role_id": role.ID, "status": input.Status}).Error; err != nil { return tx.Model(&membership).Updates(map[string]interface{}{"role_id": role.ID, "status": input.Status}).Error
return err
}
return tx.Model(&model.User{}).Where("id = ?", membership.UserID).Update("status", input.Status).Error
}) })
if err != nil { if err != nil {
response.Error(c, http.StatusInternalServerError, "UPDATE_FAILED", "更新成员失败") response.Error(c, http.StatusInternalServerError, "UPDATE_FAILED", "更新成员失败")

View File

@@ -23,6 +23,8 @@ type DetailEvent struct {
NodeTitle string `json:"node_title"` NodeTitle string `json:"node_title"`
NodeType string `json:"node_type"` NodeType string `json:"node_type"`
NodeContent string `json:"node_content"` NodeContent string `json:"node_content"`
NodeConfig datatypes.JSON `json:"-"`
Knowledge *KnowledgeView `json:"knowledge,omitempty" gorm:"-"`
} }
type DetailFeedback struct { type DetailFeedback struct {
@@ -45,10 +47,28 @@ func (h *Handler) Detail(c *gin.Context) {
return return
} }
events := make([]DetailEvent, 0) events := make([]DetailEvent, 0)
if err := h.db.Table("sop_run_events e").Select("e.*, n.title AS node_title, n.type AS node_type, n.content AS node_content").Joins("LEFT JOIN sop_nodes n ON n.sop_version_id = ? AND n.node_key = e.node_key", header.SOPVersionID).Where("e.run_id = ? AND e.tenant_id = ?", id, principal.TenantID).Order("e.created_at, e.id").Scan(&events).Error; err != nil { if err := h.db.Table("sop_run_events e").Select("e.*, n.title AS node_title, n.type AS node_type, n.content AS node_content, n.config AS node_config").Joins("LEFT JOIN sop_nodes n ON n.sop_version_id = ? AND n.node_key = e.node_key", header.SOPVersionID).Where("e.run_id = ? AND e.tenant_id = ?", id, principal.TenantID).Order("e.created_at, e.id").Scan(&events).Error; err != nil {
response.Error(c, http.StatusInternalServerError, "QUERY_FAILED", "查询执行事件失败") response.Error(c, http.StatusInternalServerError, "QUERY_FAILED", "查询执行事件失败")
return return
} }
knowledgeCache := map[string]*KnowledgeView{}
for i := range events {
if events[i].NodeType != "knowledge" {
continue
}
cacheKey := string(events[i].NodeConfig)
if cached, ok := knowledgeCache[cacheKey]; ok {
events[i].Knowledge = cached
continue
}
knowledge, err := loadKnowledgeView(h.db, model.SOPNode{Type: events[i].NodeType, Config: events[i].NodeConfig}, principal.TenantID)
if err != nil {
response.Error(c, http.StatusInternalServerError, "KNOWLEDGE_NOT_FOUND", "执行记录关联的知识卡版本不存在")
return
}
events[i].Knowledge = &knowledge
knowledgeCache[cacheKey] = &knowledge
}
feedback := make([]DetailFeedback, 0) feedback := make([]DetailFeedback, 0)
if err := h.db.Table("sop_feedback f").Select("f.*, u.display_name AS user_name").Joins("JOIN users u ON u.id = f.user_id").Where("f.run_id = ? AND f.tenant_id = ?", id, principal.TenantID).Order("f.created_at").Scan(&feedback).Error; err != nil { if err := h.db.Table("sop_feedback f").Select("f.*, u.display_name AS user_name").Joins("JOIN users u ON u.id = f.user_id").Where("f.run_id = ? AND f.tenant_id = ?", id, principal.TenantID).Order("f.created_at").Scan(&feedback).Error; err != nil {
response.Error(c, http.StatusInternalServerError, "QUERY_FAILED", "查询执行反馈失败") response.Error(c, http.StatusInternalServerError, "QUERY_FAILED", "查询执行反馈失败")

View File

@@ -11,30 +11,43 @@ func matchCondition(raw json.RawMessage, answers map[string]interface{}) (bool,
if len(raw) == 0 || string(raw) == "{}" || string(raw) == "null" { if len(raw) == 0 || string(raw) == "{}" || string(raw) == "null" {
return true, nil return true, nil
} }
var rule map[string]interface{} var rule interface{}
if err := json.Unmarshal(raw, &rule); err != nil { if err := json.Unmarshal(raw, &rule); err != nil {
return false, err return false, err
} }
if all, ok := rule["all"].([]interface{}); ok { return matchConditionValue(rule, answers)
for _, item := range all { }
object, ok := item.(map[string]interface{})
func matchConditionValue(value interface{}, answers map[string]interface{}) (bool, error) {
rule, ok := value.(map[string]interface{})
if !ok { if !ok {
return false, fmt.Errorf("invalid condition")
}
all, hasAll := rule["all"]
any, hasAny := rule["any"]
if hasAll && hasAny {
return false, fmt.Errorf("condition cannot contain both all and any")
}
if hasAll {
items, ok := all.([]interface{})
if !ok || len(items) == 0 {
return false, fmt.Errorf("invalid all condition") return false, fmt.Errorf("invalid all condition")
} }
matched, err := matchRule(object, answers) for _, item := range items {
matched, err := matchConditionValue(item, answers)
if err != nil || !matched { if err != nil || !matched {
return false, err return false, err
} }
} }
return true, nil return true, nil
} }
if any, ok := rule["any"].([]interface{}); ok { if hasAny {
for _, item := range any { items, ok := any.([]interface{})
object, ok := item.(map[string]interface{}) if !ok || len(items) == 0 {
if !ok { return false, fmt.Errorf("invalid any condition")
continue
} }
matched, err := matchRule(object, answers) for _, item := range items {
matched, err := matchConditionValue(item, answers)
if err != nil { if err != nil {
return false, err return false, err
} }

View File

@@ -3,6 +3,9 @@ package run
import ( import (
"encoding/json" "encoding/json"
"testing" "testing"
"git.iwork-ai.com/xdc/iqudo-top1/internal/model"
"gorm.io/datatypes"
) )
func TestMatchCondition(t *testing.T) { func TestMatchCondition(t *testing.T) {
@@ -17,6 +20,7 @@ func TestMatchCondition(t *testing.T) {
{name: "greater", rule: `{"field":"weight","operator":"greater_than","value":5}`, want: true}, {name: "greater", rule: `{"field":"weight","operator":"greater_than","value":5}`, want: true},
{name: "contains", rule: `{"field":"symptom","operator":"contains","value":"呕吐"}`, want: true}, {name: "contains", rule: `{"field":"symptom","operator":"contains","value":"呕吐"}`, want: true},
{name: "all", rule: `{"all":[{"field":"urgent","operator":"equals","value":true},{"field":"weight","operator":"greater_than","value":5}]}`, want: true}, {name: "all", rule: `{"all":[{"field":"urgent","operator":"equals","value":true},{"field":"weight","operator":"greater_than","value":5}]}`, want: true},
{name: "nested groups", rule: `{"all":[{"field":"urgent","operator":"equals","value":true},{"any":[{"field":"weight","operator":"less_than","value":3},{"field":"symptom","operator":"contains","value":"呕吐"}]}]}`, want: true},
{name: "any false", rule: `{"any":[{"field":"urgent","operator":"equals","value":false},{"field":"weight","operator":"less_than","value":3}]}`, want: false}, {name: "any false", rule: `{"any":[{"field":"urgent","operator":"equals","value":false},{"field":"weight","operator":"less_than","value":3}]}`, want: false},
} }
for _, test := range tests { for _, test := range tests {
@@ -31,3 +35,22 @@ func TestMatchCondition(t *testing.T) {
}) })
} }
} }
func TestMatchConditionRejectsAmbiguousGroup(t *testing.T) {
_, err := matchCondition(json.RawMessage(`{"all":[{"field":"urgent","operator":"equals","value":true}],"any":[{"field":"urgent","operator":"equals","value":true}]}`), map[string]interface{}{"urgent": true})
if err == nil {
t.Fatal("matchCondition() should reject a condition containing both all and any")
}
}
func TestSortEdgesAlwaysPlacesDefaultLast(t *testing.T) {
edges := []model.SOPEdge{
{TargetNodeKey: "default", Condition: datatypes.JSON([]byte(`{}`)), Priority: 0},
{TargetNodeKey: "second", Condition: datatypes.JSON([]byte(`{"field":"urgent","operator":"equals","value":false}`)), Priority: 20},
{TargetNodeKey: "first", Condition: datatypes.JSON([]byte(`{"field":"urgent","operator":"equals","value":true}`)), Priority: 10},
}
sortEdges(edges)
if edges[0].TargetNodeKey != "first" || edges[1].TargetNodeKey != "second" || edges[2].TargetNodeKey != "default" {
t.Fatalf("sortEdges() order = %s, %s, %s", edges[0].TargetNodeKey, edges[1].TargetNodeKey, edges[2].TargetNodeKey)
}
}

View File

@@ -23,6 +23,19 @@ type Handler struct {
db *gorm.DB db *gorm.DB
} }
type listItem struct {
model.SOPRun
SOPName string `json:"sop_name"`
ScenarioName string `json:"scenario_name"`
Version int `json:"version"`
OperatorName string `json:"operator_name"`
}
type filterOption struct {
Value uint64 `json:"value"`
Label string `json:"label"`
}
func NewHandler(db *gorm.DB) *Handler { func NewHandler(db *gorm.DB) *Handler {
return &Handler{db: db} return &Handler{db: db}
} }
@@ -101,12 +114,12 @@ func (h *Handler) Get(c *gin.Context) {
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 {
model.SOPRun
SOPName string `json:"sop_name"`
}
page, pageSize := pagination(c) page, pageSize := pagination(c)
query := scopeRuns(h.db.Table("sop_runs r").Joins("JOIN sops s ON s.id = r.sop_id"), p, "r") query := scopeRuns(h.db.Table("sop_runs r").
Joins("JOIN sops s ON s.id = r.sop_id").
Joins("JOIN scenarios sc ON sc.id = s.scenario_id").
Joins("JOIN sop_versions sv ON sv.id = r.sop_version_id").
Joins("JOIN users u ON u.id = r.operator_id"), p, "r")
if status := c.Query("status"); status != "" { if status := c.Query("status"); status != "" {
query = query.Where("r.status = ?", status) query = query.Where("r.status = ?", status)
} }
@@ -116,6 +129,9 @@ func (h *Handler) List(c *gin.Context) {
if sopID := c.Query("sop_id"); sopID != "" { if sopID := c.Query("sop_id"); sopID != "" {
query = query.Where("r.sop_id = ?", sopID) query = query.Where("r.sop_id = ?", sopID)
} }
if operatorID := c.Query("operator_id"); operatorID != "" {
query = query.Where("r.operator_id = ?", operatorID)
}
if startedFrom := c.Query("started_from"); startedFrom != "" { if startedFrom := c.Query("started_from"); startedFrom != "" {
query = query.Where("r.started_at >= ?", startedFrom) query = query.Where("r.started_at >= ?", startedFrom)
} }
@@ -127,14 +143,37 @@ func (h *Handler) List(c *gin.Context) {
response.Error(c, http.StatusInternalServerError, "QUERY_FAILED", "查询执行记录失败") response.Error(c, http.StatusInternalServerError, "QUERY_FAILED", "查询执行记录失败")
return return
} }
items := make([]row, 0) items := make([]listItem, 0)
if err := query.Select("r.*, s.name AS sop_name").Order("r.created_at DESC").Offset((page - 1) * pageSize).Limit(pageSize).Scan(&items).Error; err != nil { if err := query.Select("r.*, s.name AS sop_name, sc.name AS scenario_name, sv.version, u.display_name AS operator_name").Order("r.created_at DESC").Offset((page - 1) * pageSize).Limit(pageSize).Scan(&items).Error; err != nil {
response.Error(c, http.StatusInternalServerError, "QUERY_FAILED", "查询执行记录失败") response.Error(c, http.StatusInternalServerError, "QUERY_FAILED", "查询执行记录失败")
return return
} }
response.OK(c, gin.H{"items": items, "total": total, "page": page, "page_size": pageSize}) response.OK(c, gin.H{"items": items, "total": total, "page": page, "page_size": pageSize})
} }
func (h *Handler) Options(c *gin.Context) {
p, _ := auth.PrincipalFromContext(c)
sops := make([]filterOption, 0)
sopQuery := scopeRuns(h.db.Table("sop_runs r"), p, "r").
Select("DISTINCT s.id AS value, s.name AS label").
Joins("JOIN sops s ON s.id = r.sop_id").
Order("s.name, s.id")
if err := sopQuery.Scan(&sops).Error; err != nil {
response.Error(c, http.StatusInternalServerError, "QUERY_FAILED", "查询 SOP 筛选项失败")
return
}
operators := make([]filterOption, 0)
operatorQuery := scopeRuns(h.db.Table("sop_runs r"), p, "r").
Select("DISTINCT u.id AS value, u.display_name AS label").
Joins("JOIN users u ON u.id = r.operator_id").
Order("u.display_name, u.id")
if err := operatorQuery.Scan(&operators).Error; err != nil {
response.Error(c, http.StatusInternalServerError, "QUERY_FAILED", "查询执行人筛选项失败")
return
}
response.OK(c, gin.H{"sops": sops, "operators": operators})
}
func (h *Handler) Answer(c *gin.Context) { func (h *Handler) Answer(c *gin.Context) {
p, _ := auth.PrincipalFromContext(c) p, _ := auth.PrincipalFromContext(c)
id, ok := runID(c) id, ok := runID(c)
@@ -185,7 +224,7 @@ func (h *Handler) Answer(c *gin.Context) {
if err := tx.Where("sop_version_id = ? AND source_node_key = ?", updated.SOPVersionID, updated.CurrentNodeKey).Order("priority, id").Find(&edges).Error; err != nil { if err := tx.Where("sop_version_id = ? AND source_node_key = ?", updated.SOPVersionID, updated.CurrentNodeKey).Order("priority, id").Find(&edges).Error; err != nil {
return err return err
} }
sort.SliceStable(edges, func(i, j int) bool { return edges[i].Priority < edges[j].Priority }) sortEdges(edges)
nextKey := "" nextKey := ""
for _, edge := range edges { for _, edge := range edges {
matched, matchErr := matchCondition(json.RawMessage(edge.Condition), answers) matched, matchErr := matchCondition(json.RawMessage(edge.Condition), answers)
@@ -233,6 +272,29 @@ func (h *Handler) Answer(c *gin.Context) {
h.respondRun(c, updated) h.respondRun(c, updated)
} }
func sortEdges(edges []model.SOPEdge) {
sort.SliceStable(edges, func(i, j int) bool {
leftDefault := defaultCondition(edges[i].Condition)
rightDefault := defaultCondition(edges[j].Condition)
if leftDefault != rightDefault {
return !leftDefault
}
return edges[i].Priority < edges[j].Priority
})
}
func defaultCondition(raw []byte) bool {
if len(raw) == 0 {
return true
}
var value interface{}
if err := json.Unmarshal(raw, &value); err != nil || value == nil {
return err == nil && value == nil
}
object, ok := value.(map[string]interface{})
return ok && len(object) == 0
}
func (h *Handler) Finish(c *gin.Context) { func (h *Handler) Finish(c *gin.Context) {
p, _ := auth.PrincipalFromContext(c) p, _ := auth.PrincipalFromContext(c)
id, ok := runID(c) id, ok := runID(c)
@@ -240,29 +302,51 @@ func (h *Handler) Finish(c *gin.Context) {
return return
} }
var input struct { var input struct {
Result string `json:"result" binding:"required,max=64"` Result string `json:"result" binding:"required,oneof=manual"`
} }
if err := c.ShouldBindJSON(&input); err != nil { if err := c.ShouldBindJSON(&input); err != nil {
response.Error(c, http.StatusBadRequest, "INVALID_ARGUMENT", "请选择执行结果") response.Error(c, http.StatusBadRequest, "INVALID_ARGUMENT", "请选择执行结果")
return return
} }
var run model.SOPRun var run model.SOPRun
if err := h.db.Where("id = ? AND tenant_id = ?", id, p.TenantID).First(&run).Error; err != nil || !canOperateRun(p, run) { runCompletedErr := errors.New("执行记录已经结束")
response.Error(c, http.StatusNotFound, "NOT_FOUND", "执行记录不存在") err := h.db.Transaction(func(tx *gorm.DB) error {
return if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("id = ? AND tenant_id = ?", id, p.TenantID).First(&run).Error; err != nil {
return err
}
if !canOperateRun(p, run) {
return gorm.ErrRecordNotFound
} }
if run.Status != "running" { if run.Status != "running" {
response.Error(c, http.StatusConflict, "RUN_COMPLETED", "执行记录已经结束") return runCompletedErr
return
} }
now := time.Now() now := time.Now()
result := h.db.Model(&model.SOPRun{}).Where("id = ? AND tenant_id = ? AND status = ?", id, p.TenantID, "running").Updates(map[string]interface{}{"status": "completed", "result": input.Result, "completed_at": &now}) payload, _ := json.Marshal(input)
if result.Error != nil || result.RowsAffected == 0 { if err := tx.Create(&model.SOPRunEvent{TenantID: p.TenantID, RunID: id, NodeKey: run.CurrentNodeKey, Action: "finish", Payload: datatypes.JSON(payload)}).Error; err != nil {
return err
}
if err := tx.Model(&run).Updates(map[string]interface{}{"status": "completed", "result": input.Result, "completed_at": &now}).Error; err != nil {
return err
}
run.Status = "completed"
run.Result = input.Result
run.CompletedAt = &now
return nil
})
if errors.Is(err, gorm.ErrRecordNotFound) {
response.Error(c, http.StatusNotFound, "NOT_FOUND", "执行记录不存在") response.Error(c, http.StatusNotFound, "NOT_FOUND", "执行记录不存在")
return return
} }
if errors.Is(err, runCompletedErr) {
response.Error(c, http.StatusConflict, "RUN_COMPLETED", err.Error())
return
}
if err != nil {
response.Error(c, http.StatusInternalServerError, "FINISH_FAILED", "结束执行失败")
return
}
_ = audit.Record(h.db, p, "finish", "sop_run", id, input) _ = audit.Record(h.db, p, "finish", "sop_run", id, input)
h.Get(c) h.respondRun(c, run)
} }
func (h *Handler) Feedback(c *gin.Context) { func (h *Handler) Feedback(c *gin.Context) {
@@ -299,13 +383,21 @@ func (h *Handler) Feedback(c *gin.Context) {
func (h *Handler) respondRun(c *gin.Context, item model.SOPRun) { func (h *Handler) respondRun(c *gin.Context, item model.SOPRun) {
var node model.SOPNode var node model.SOPNode
if err := h.db.Where("sop_version_id = ? AND node_key = ?", item.SOPVersionID, item.CurrentNodeKey).First(&node).Error; err != nil { if err := h.db.Where("sop_version_id = ? AND node_key = ? AND tenant_id = ?", item.SOPVersionID, item.CurrentNodeKey, item.TenantID).First(&node).Error; err != nil {
response.Error(c, http.StatusInternalServerError, "NODE_NOT_FOUND", "当前流程节点不存在") response.Error(c, http.StatusInternalServerError, "NODE_NOT_FOUND", "当前流程节点不存在")
return return
} }
nodeView, err := h.nodeView(h.db, node, item.TenantID)
if err != nil {
response.Error(c, http.StatusInternalServerError, "KNOWLEDGE_NOT_FOUND", "当前节点关联的知识卡版本不存在")
return
}
fields := make([]model.ScenarioField, 0) fields := make([]model.ScenarioField, 0)
h.db.Table("scenario_fields sf").Joins("JOIN sops s ON s.scenario_id = sf.scenario_id").Where("s.id = ? AND sf.tenant_id = ?", item.SOPID, item.TenantID).Order("sf.sort_order, sf.id").Find(&fields) if err := h.db.Table("scenario_fields sf").Joins("JOIN sops s ON s.scenario_id = sf.scenario_id").Where("s.id = ? AND sf.tenant_id = ?", item.SOPID, item.TenantID).Order("sf.sort_order, sf.id").Find(&fields).Error; err != nil {
response.OK(c, gin.H{"run": item, "node": node, "fields": fields}) response.Error(c, http.StatusInternalServerError, "QUERY_FAILED", "查询场景字段失败")
return
}
response.OK(c, gin.H{"run": item, "node": nodeView, "fields": fields})
} }
func runID(c *gin.Context) (uint64, bool) { func runID(c *gin.Context) (uint64, bool) {

76
internal/run/knowledge.go Normal file
View File

@@ -0,0 +1,76 @@
package run
import (
"encoding/json"
"git.iwork-ai.com/xdc/iqudo-top1/internal/model"
"gorm.io/gorm"
)
type KnowledgeView struct {
CardID uint64 `json:"card_id"`
CardVersionID uint64 `json:"card_version_id"`
Version int `json:"version"`
Title string `json:"title"`
StandardCopy string `json:"standard_copy"`
ForbiddenCopy string `json:"forbidden_copy"`
RiskNote string `json:"risk_note"`
}
type NodeView struct {
model.SOPNode
Knowledge *KnowledgeView `json:"knowledge,omitempty"`
}
type knowledgeNodeConfig struct {
KnowledgeCardID uint64 `json:"knowledge_card_id"`
KnowledgeCardVersionID uint64 `json:"knowledge_card_version_id"`
}
func (h *Handler) nodeView(db *gorm.DB, node model.SOPNode, tenantID uint64) (NodeView, error) {
view := NodeView{SOPNode: node}
if node.Type != "knowledge" {
return view, nil
}
knowledge, err := loadKnowledgeView(db, node, tenantID)
if err != nil {
return view, err
}
view.Knowledge = &knowledge
return view, nil
}
func loadKnowledgeView(db *gorm.DB, node model.SOPNode, tenantID uint64) (KnowledgeView, error) {
var config knowledgeNodeConfig
if err := json.Unmarshal(node.Config, &config); err != nil {
return KnowledgeView{}, err
}
type row struct {
model.KnowledgeCardVersion
Title string `gorm:"column:title"`
}
var version row
query := db.Table("knowledge_card_versions kv").Select("kv.*, kc.title").
Joins("JOIN knowledge_cards kc ON kc.id = kv.knowledge_card_id").
Where("kv.tenant_id = ? AND kc.tenant_id = ? AND kv.knowledge_card_id = ?", tenantID, tenantID, config.KnowledgeCardID)
if config.KnowledgeCardVersionID != 0 {
query = query.Where("kv.id = ?", config.KnowledgeCardVersionID)
} else {
query = query.Where("kv.status = ?", "published").Order("kv.version DESC")
}
if err := query.First(&version).Error; err != nil {
return KnowledgeView{}, err
}
var content struct {
StandardCopy string `json:"standard_copy"`
ForbiddenCopy string `json:"forbidden_copy"`
RiskNote string `json:"risk_note"`
}
if err := json.Unmarshal(version.Content, &content); err != nil {
return KnowledgeView{}, err
}
return KnowledgeView{
CardID: version.KnowledgeCardID, CardVersionID: version.ID, Version: version.Version, Title: version.Title,
StandardCopy: content.StandardCopy, ForbiddenCopy: content.ForbiddenCopy, RiskNote: content.RiskNote,
}, nil
}

View File

@@ -13,6 +13,7 @@ type answerNodeConfig struct {
FieldKey string `json:"field_key"` FieldKey string `json:"field_key"`
FieldKeys []string `json:"field_keys"` FieldKeys []string `json:"field_keys"`
Required bool `json:"required"` Required bool `json:"required"`
Options []string `json:"options"`
} }
func validateNodeAnswers(node model.SOPNode, fields []model.ScenarioField, answers map[string]interface{}) error { func validateNodeAnswers(node model.SOPNode, fields []model.ScenarioField, answers map[string]interface{}) error {
@@ -68,10 +69,22 @@ func validateNodeAnswers(node model.SOPNode, fields []model.ScenarioField, answe
if err := validateFieldValue(field, value); err != nil { if err := validateFieldValue(field, value); err != nil {
return err return err
} }
if node.Type == "choice" && !stringAllowed(config.Options, fmt.Sprint(value)) {
return fmt.Errorf("%s的选项不正确", field.FieldName)
}
} }
return nil return nil
} }
func stringAllowed(options []string, selected string) bool {
for _, option := range options {
if option == selected {
return true
}
}
return false
}
func validateFieldValue(field model.ScenarioField, value interface{}) error { func validateFieldValue(field model.ScenarioField, value interface{}) error {
switch field.FieldType { switch field.FieldType {
case "text", "textarea": case "text", "textarea":

View File

@@ -46,3 +46,15 @@ func TestValidateNodeAnswersRejectsAnswersForMessage(t *testing.T) {
t.Fatalf("validateNodeAnswers() error = %v", err) t.Fatalf("validateNodeAnswers() error = %v", err)
} }
} }
func TestValidateNodeAnswersChoiceOptions(t *testing.T) {
fields := []model.ScenarioField{{FieldKey: "intent", FieldName: "客户意向", FieldType: "text"}}
node := model.SOPNode{Type: "choice", Config: datatypes.JSON([]byte(`{"field_key":"intent","required":true,"options":["继续了解","暂不考虑"]}`))}
if err := validateNodeAnswers(node, fields, map[string]interface{}{"intent": "继续了解"}); err != nil {
t.Fatalf("validateNodeAnswers() error = %v", err)
}
err := validateNodeAnswers(node, fields, map[string]interface{}{"intent": "绕过配置的值"})
if err == nil || !strings.Contains(err.Error(), "选项不正确") {
t.Fatalf("validateNodeAnswers() error = %v, want invalid choice option", err)
}
}

View File

@@ -19,6 +19,13 @@ func TestValidateFieldInput(t *testing.T) {
{name: "options null", input: fieldInput{FieldKey: "pet_note", FieldType: "text", Options: json.RawMessage(`null`)}, 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 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}, {name: "validation null", input: fieldInput{FieldKey: "pet_age", FieldType: "number", Validation: json.RawMessage(`null`)}, wantErr: true},
{name: "number range", input: fieldInput{FieldKey: "pet_age", FieldType: "number", Validation: json.RawMessage(`{"min":0,"max":50}`)}},
{name: "reversed number range", input: fieldInput{FieldKey: "pet_age", FieldType: "number", Validation: json.RawMessage(`{"min":50,"max":0}`)}, wantErr: true},
{name: "number rule on text", input: fieldInput{FieldKey: "pet_name", FieldType: "text", Validation: json.RawMessage(`{"min":1}`)}, wantErr: true},
{name: "text range", input: fieldInput{FieldKey: "pet_name", FieldType: "text", Validation: json.RawMessage(`{"min_length":1,"max_length":50}`)}},
{name: "fractional text length", input: fieldInput{FieldKey: "pet_name", FieldType: "text", Validation: json.RawMessage(`{"min_length":1.5}`)}, wantErr: true},
{name: "negative text length", input: fieldInput{FieldKey: "pet_name", FieldType: "text", Validation: json.RawMessage(`{"min_length":-1}`)}, wantErr: true},
{name: "duplicate options", input: fieldInput{FieldKey: "pet_type", FieldType: "select", Options: json.RawMessage(`["猫","猫"]`)}, wantErr: true},
} }
for _, test := range tests { for _, test := range tests {
t.Run(test.name, func(t *testing.T) { t.Run(test.name, func(t *testing.T) {

View File

@@ -278,10 +278,16 @@ func validateFieldInput(input fieldInput) error {
if err := json.Unmarshal(input.Options, &options); err != nil || options == nil { if err := json.Unmarshal(input.Options, &options); err != nil || options == nil {
return errors.New("字段选项必须是文本数组") return errors.New("字段选项必须是文本数组")
} }
seen := map[string]bool{}
for _, option := range options { for _, option := range options {
if strings.TrimSpace(option) == "" { if strings.TrimSpace(option) == "" {
return errors.New("字段选项不能为空") return errors.New("字段选项不能为空")
} }
normalized := strings.TrimSpace(option)
if seen[normalized] {
return errors.New("字段选项不能重复")
}
seen[normalized] = true
} }
if (input.FieldType == "select" || input.FieldType == "multiselect") && len(options) == 0 { if (input.FieldType == "select" || input.FieldType == "multiselect") && len(options) == 0 {
return errors.New("单选或多选字段至少需要一个选项") return errors.New("单选或多选字段至少需要一个选项")
@@ -290,14 +296,77 @@ func validateFieldInput(input fieldInput) error {
return errors.New("单选或多选字段至少需要一个选项") return errors.New("单选或多选字段至少需要一个选项")
} }
if len(input.Validation) > 0 { if len(input.Validation) > 0 {
var validation map[string]interface{} var validation map[string]json.RawMessage
if err := json.Unmarshal(input.Validation, &validation); err != nil || validation == nil { if err := json.Unmarshal(input.Validation, &validation); err != nil || validation == nil {
return errors.New("字段校验规则必须是 JSON 对象") return errors.New("字段校验规则必须是 JSON 对象")
} }
if err := validateFieldRules(input.FieldType, validation); err != nil {
return err
}
} }
return nil return nil
} }
func validateFieldRules(fieldType string, validation map[string]json.RawMessage) error {
allowed := map[string]bool{}
switch fieldType {
case "number":
allowed["min"], allowed["max"] = true, true
case "text", "textarea":
allowed["min_length"], allowed["max_length"] = true, true
}
for key := range validation {
if !allowed[key] {
return errors.New("当前字段类型不支持校验规则 " + key)
}
}
if fieldType == "number" {
min, hasMin, err := jsonNumber(validation["min"])
if err != nil {
return errors.New("最小值必须是数字")
}
max, hasMax, err := jsonNumber(validation["max"])
if err != nil {
return errors.New("最大值必须是数字")
}
if hasMin && hasMax && min > max {
return errors.New("最小值不能大于最大值")
}
}
if fieldType == "text" || fieldType == "textarea" {
min, hasMin, err := jsonInteger(validation["min_length"])
if err != nil || (hasMin && min < 0) {
return errors.New("最少字符必须是非负整数")
}
max, hasMax, err := jsonInteger(validation["max_length"])
if err != nil || (hasMax && max < 1) {
return errors.New("最多字符必须是正整数")
}
if hasMin && hasMax && min > max {
return errors.New("最少字符不能大于最多字符")
}
}
return nil
}
func jsonNumber(raw json.RawMessage) (float64, bool, error) {
if len(raw) == 0 {
return 0, false, nil
}
var value float64
err := json.Unmarshal(raw, &value)
return value, true, err
}
func jsonInteger(raw json.RawMessage) (int, bool, error) {
if len(raw) == 0 {
return 0, false, nil
}
var value int
err := json.Unmarshal(raw, &value)
return value, true, err
}
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

@@ -355,6 +355,9 @@ func (h *Handler) Publish(c *gin.Context) {
} }
now := time.Now() now := time.Now()
err = h.db.Transaction(func(tx *gorm.DB) error { err = h.db.Transaction(func(tx *gorm.DB) error {
if err := BindKnowledgeVersions(tx, p.TenantID, version.ID); 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 { 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 return err
} }

View File

@@ -0,0 +1,58 @@
package sop
import (
"encoding/json"
"fmt"
"git.iwork-ai.com/xdc/iqudo-top1/internal/model"
"gorm.io/datatypes"
"gorm.io/gorm"
)
type knowledgeNodeConfig struct {
KnowledgeCardID uint64 `json:"knowledge_card_id"`
KnowledgeCardVersionID uint64 `json:"knowledge_card_version_id"`
}
// BindKnowledgeVersions freezes the current published knowledge-card version
// into every knowledge node before the SOP version becomes immutable.
func BindKnowledgeVersions(tx *gorm.DB, tenantID, sopVersionID uint64) error {
nodes := make([]model.SOPNode, 0)
if err := tx.Where("tenant_id = ? AND sop_version_id = ? AND type = ?", tenantID, sopVersionID, "knowledge").Find(&nodes).Error; err != nil {
return err
}
for _, node := range nodes {
var config knowledgeNodeConfig
if err := json.Unmarshal(node.Config, &config); err != nil || config.KnowledgeCardID == 0 {
return fmt.Errorf("knowledge node %s has invalid configuration", node.NodeKey)
}
var version model.KnowledgeCardVersion
err := tx.Table("knowledge_card_versions kv").Select("kv.*").
Joins("JOIN knowledge_cards kc ON kc.id = kv.knowledge_card_id").
Where("kv.tenant_id = ? AND kv.knowledge_card_id = ? AND kv.status = ? AND kc.tenant_id = ? AND kc.status = ?", tenantID, config.KnowledgeCardID, "published", tenantID, "published").
Order("kv.version DESC").First(&version).Error
if err != nil {
return fmt.Errorf("knowledge node %s has no published card version: %w", node.NodeKey, err)
}
updated, err := withKnowledgeVersion(node.Config, version.ID)
if err != nil {
return fmt.Errorf("update knowledge node %s: %w", node.NodeKey, err)
}
if err := tx.Model(&model.SOPNode{}).Where("id = ? AND tenant_id = ? AND sop_version_id = ?", node.ID, tenantID, sopVersionID).Update("config", updated).Error; err != nil {
return err
}
}
return nil
}
func withKnowledgeVersion(config datatypes.JSON, versionID uint64) (datatypes.JSON, error) {
value := map[string]interface{}{}
if len(config) > 0 {
if err := json.Unmarshal(config, &value); err != nil {
return nil, err
}
}
value["knowledge_card_version_id"] = versionID
encoded, err := json.Marshal(value)
return datatypes.JSON(encoded), err
}

View File

@@ -0,0 +1,22 @@
package sop
import (
"encoding/json"
"testing"
"gorm.io/datatypes"
)
func TestWithKnowledgeVersionPreservesConfiguration(t *testing.T) {
updated, err := withKnowledgeVersion(datatypes.JSON([]byte(`{"knowledge_card_id":12,"display":"full"}`)), 34)
if err != nil {
t.Fatal(err)
}
var value map[string]interface{}
if err := json.Unmarshal(updated, &value); err != nil {
t.Fatal(err)
}
if value["knowledge_card_id"] != float64(12) || value["knowledge_card_version_id"] != float64(34) || value["display"] != "full" {
t.Fatalf("unexpected knowledge config: %#v", value)
}
}

View File

@@ -54,6 +54,8 @@ func ValidateGraph(startNodeKey string, nodes []model.SOPNode, edges []model.SOP
} }
if _, exists := nodeMap[startNodeKey]; !exists && startNodeKey != "" { if _, exists := nodeMap[startNodeKey]; !exists && startNodeKey != "" {
problems = append(problems, "开始节点不存在") problems = append(problems, "开始节点不存在")
} else if startNodeKey != "" && nodeMap[startNodeKey].Type != "start" {
problems = append(problems, "开始节点标识必须指向 start 类型节点")
} }
adjacency := make(map[string][]string) adjacency := make(map[string][]string)
@@ -77,6 +79,9 @@ func ValidateGraph(startNodeKey string, nodes []model.SOPNode, edges []model.SOP
if node.Type != "finish" && node.Type != "escalate" && outgoing[node.NodeKey] == 0 { if node.Type != "finish" && node.Type != "escalate" && outgoing[node.NodeKey] == 0 {
problems = append(problems, fmt.Sprintf("节点“%s”没有下一步", node.Title)) problems = append(problems, fmt.Sprintf("节点“%s”没有下一步", node.Title))
} }
if (node.Type == "finish" || node.Type == "escalate") && outgoing[node.NodeKey] > 0 {
problems = append(problems, fmt.Sprintf("结束节点“%s”不能配置下一步", node.Title))
}
} }
visited := map[string]bool{} visited := map[string]bool{}
@@ -147,6 +152,9 @@ func ValidateForPublish(startNodeKey string, nodes []model.SOPNode, edges []mode
problems = append(problems, fmt.Sprintf("节点“%s”引用的字段 %s 不存在", node.Title, fieldKey)) problems = append(problems, fmt.Sprintf("节点“%s”引用的字段 %s 不存在", node.Title, fieldKey))
} else { } else {
collected[fieldKey] = true collected[fieldKey] = true
if node.Type == "choice" {
validateChoiceOptions(node, config, fieldMap[fieldKey], &problems)
}
} }
case "form": case "form":
keys, _ := config["field_keys"].([]interface{}) keys, _ := config["field_keys"].([]interface{})
@@ -218,12 +226,57 @@ func ValidateForPublish(startNodeKey string, nodes []model.SOPNode, edges []mode
return problems return problems
} }
func validateChoiceOptions(node model.SOPNode, config map[string]interface{}, field model.ScenarioField, problems *[]string) {
values, ok := config["options"].([]interface{})
if !ok || len(values) == 0 {
*problems = append(*problems, fmt.Sprintf("选择节点“%s”没有配置选项", node.Title))
return
}
if field.FieldType != "text" && field.FieldType != "select" {
*problems = append(*problems, fmt.Sprintf("选择节点“%s”只能写入文本或单选字段", node.Title))
}
seen := map[string]bool{}
for _, value := range values {
option, ok := value.(string)
if !ok || option == "" {
*problems = append(*problems, fmt.Sprintf("选择节点“%s”包含无效选项", node.Title))
continue
}
if seen[option] {
*problems = append(*problems, fmt.Sprintf("选择节点“%s”包含重复选项", node.Title))
}
seen[option] = true
if field.FieldType == "select" && !jsonStringArrayContains(field.Options, option) {
*problems = append(*problems, fmt.Sprintf("选择节点“%s”的选项“%s”不在字段选项中", node.Title, option))
}
}
}
func jsonStringArrayContains(raw []byte, expected string) bool {
var values []string
if json.Unmarshal(raw, &values) != nil {
return false
}
for _, value := range values {
if value == expected {
return true
}
}
return false
}
func validateCondition(value interface{}, fields map[string]model.ScenarioField, label string, problems *[]string) { func validateCondition(value interface{}, fields map[string]model.ScenarioField, label string, problems *[]string) {
rule, ok := value.(map[string]interface{}) rule, ok := value.(map[string]interface{})
if !ok { if !ok {
*problems = append(*problems, label+"的条件结构不正确") *problems = append(*problems, label+"的条件结构不正确")
return return
} }
_, hasAll := rule["all"]
_, hasAny := rule["any"]
if hasAll && hasAny {
*problems = append(*problems, label+"不能同时包含全部满足和任一满足")
return
}
for _, group := range []string{"all", "any"} { for _, group := range []string{"all", "any"} {
if raw, exists := rule[group]; exists { if raw, exists := rule[group]; exists {
items, ok := raw.([]interface{}) items, ok := raw.([]interface{})

View File

@@ -42,6 +42,12 @@ func TestValidateForPublishBusinessRules(t *testing.T) {
{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: "没有明确的转人工或转诊路径"},
{name: "terminal node has outgoing edge", mutate: func(_ *[]model.SOPNode, edges *[]model.SOPEdge, _ *ValidationContext) {
*edges = append(*edges, model.SOPEdge{SourceNodeKey: "finish", TargetNodeKey: "screen", Condition: jsonData(`{}`)})
}, want: "不能配置下一步"},
{name: "ambiguous condition group", mutate: func(_ *[]model.SOPNode, edges *[]model.SOPEdge, _ *ValidationContext) {
(*edges)[1].Condition = jsonData(`{"all":[{"field":"emergency","operator":"equals","value":true}],"any":[{"field":"emergency","operator":"equals","value":true}]}`)
}, want: "不能同时包含全部满足和任一满足"},
} }
for _, test := range tests { for _, test := range tests {
t.Run(test.name, func(t *testing.T) { t.Run(test.name, func(t *testing.T) {
@@ -55,6 +61,29 @@ func TestValidateForPublishBusinessRules(t *testing.T) {
} }
} }
func TestValidateGraphRequiresConfiguredStartType(t *testing.T) {
nodes, edges, _ := validPublishGraph()
if problems := ValidateGraph("screen", nodes, edges); !containsProblem(problems, "必须指向 start 类型节点") {
t.Fatalf("ValidateGraph() problems = %v", problems)
}
}
func TestValidateForPublishChoiceConfiguration(t *testing.T) {
nodes := []model.SOPNode{
{NodeKey: "start", Type: "start", Title: "开始", Config: jsonData(`{}`)},
{NodeKey: "choice", Type: "choice", Title: "客户意向", Config: jsonData(`{"field_key":"intent","options":["继续","无效"]}`)},
{NodeKey: "finish", Type: "finish", Title: "结束", Config: jsonData(`{}`)},
}
edges := []model.SOPEdge{
{SourceNodeKey: "start", TargetNodeKey: "choice", Condition: jsonData(`{}`)},
{SourceNodeKey: "choice", TargetNodeKey: "finish", Condition: jsonData(`{}`)},
}
context := ValidationContext{Fields: []model.ScenarioField{{FieldKey: "intent", FieldName: "客户意向", FieldType: "select", Options: jsonData(`["继续","暂不考虑"]`)}}}
if problems := ValidateForPublish("start", nodes, edges, context); !containsProblem(problems, "不在字段选项中") {
t.Fatalf("ValidateForPublish() problems = %v", problems)
}
}
func TestValidateGraphRejectsNonTerminatingCycle(t *testing.T) { func TestValidateGraphRejectsNonTerminatingCycle(t *testing.T) {
nodes := []model.SOPNode{ nodes := []model.SOPNode{
{NodeKey: "start", Type: "start", Title: "开始", Config: jsonData(`{}`)}, {NodeKey: "start", Type: "start", Title: "开始", Config: jsonData(`{}`)},

View File

@@ -42,6 +42,8 @@ func main() {
os.Exit(1) os.Exit(1)
} }
defer log.Sync() defer log.Sync()
undoGlobalLogger := zap.ReplaceGlobals(log.Named("business"))
defer undoGlobalLogger()
db, err := database.Open(cfg.Database, log) db, err := database.Open(cfg.Database, log)
if err != nil { if err != nil {

View File

@@ -0,0 +1,4 @@
UPDATE sop_nodes
SET config = JSON_REMOVE(config, '$.knowledge_card_version_id')
WHERE type = 'knowledge'
AND JSON_EXTRACT(config, '$.knowledge_card_version_id') IS NOT NULL;

View File

@@ -0,0 +1,25 @@
UPDATE sop_nodes n
JOIN sop_versions sv ON sv.id = n.sop_version_id
SET n.config = JSON_SET(
n.config,
'$.knowledge_card_version_id',
(
SELECT kv.id
FROM knowledge_card_versions kv
WHERE kv.tenant_id = n.tenant_id
AND kv.knowledge_card_id = CAST(JSON_UNQUOTE(JSON_EXTRACT(n.config, '$.knowledge_card_id')) AS UNSIGNED)
AND kv.created_at <= COALESCE(sv.published_at, sv.updated_at)
ORDER BY kv.version DESC
LIMIT 1
)
)
WHERE n.type = 'knowledge'
AND JSON_EXTRACT(n.config, '$.knowledge_card_version_id') IS NULL
AND JSON_EXTRACT(n.config, '$.knowledge_card_id') IS NOT NULL
AND EXISTS (
SELECT 1
FROM knowledge_card_versions kv
WHERE kv.tenant_id = n.tenant_id
AND kv.knowledge_card_id = CAST(JSON_UNQUOTE(JSON_EXTRACT(n.config, '$.knowledge_card_id')) AS UNSIGNED)
AND kv.created_at <= COALESCE(sv.published_at, sv.updated_at)
);

View File

@@ -5,12 +5,14 @@ import (
"flag" "flag"
"fmt" "fmt"
"os" "os"
"reflect"
"time" "time"
"git.iwork-ai.com/xdc/iqudo-top1/internal/config" "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/database"
"git.iwork-ai.com/xdc/iqudo-top1/internal/logger" "git.iwork-ai.com/xdc/iqudo-top1/internal/logger"
"git.iwork-ai.com/xdc/iqudo-top1/internal/model" "git.iwork-ai.com/xdc/iqudo-top1/internal/model"
sopservice "git.iwork-ai.com/xdc/iqudo-top1/internal/sop"
"go.uber.org/zap" "go.uber.org/zap"
"gorm.io/datatypes" "gorm.io/datatypes"
"gorm.io/gorm" "gorm.io/gorm"
@@ -124,29 +126,7 @@ func seedScenario(tx *gorm.DB, tenantID, userID uint64) (model.Scenario, error)
} }
func seedFields(tx *gorm.DB, tenantID, scenarioID uint64) error { func seedFields(tx *gorm.DB, tenantID, scenarioID uint64) error {
definitions := []fieldDefinition{ definitions := petFieldDefinitions()
{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 { for index, definition := range definitions {
options, _ := json.Marshal(definition.Options) options, _ := json.Marshal(definition.Options)
if definition.Options == nil { if definition.Options == nil {
@@ -168,10 +148,67 @@ func seedFields(tx *gorm.DB, tenantID, scenarioID uint64) error {
return nil return nil
} }
func petFieldDefinitions() []fieldDefinition {
return []fieldDefinition{
{Key: "consultation_channel", Name: "咨询渠道", Type: "select", Required: true, Options: []string{"到店", "电话", "在线图文", "视频"}},
{Key: "visit_goal", Name: "本次咨询目的", Type: "select", Required: true, Options: []string{"症状咨询", "用药咨询", "复诊随访", "检查报告解读", "其他"}},
{Key: "pet_name", Name: "宠物名称", Type: "text", Required: true, Validation: map[string]interface{}{"max_length": 50}},
{Key: "pet_type", Name: "宠物种类", Type: "select", Required: true, Options: []string{"犬", "猫", "其他"}},
{Key: "pet_breed", Name: "品种", Type: "text", Validation: map[string]interface{}{"max_length": 50}},
{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: "pregnancy_status", Name: "妊娠或哺乳情况", Type: "select", Options: []string{"不适用", "否", "可能妊娠", "妊娠中", "哺乳中", "不清楚"}},
{Key: "symptom", Name: "主要症状", Type: "textarea", Required: true, Validation: map[string]interface{}{"max_length": 1000}},
{Key: "symptom_duration", Name: "症状开始时间及持续时长", Type: "text", Required: true, Validation: map[string]interface{}{"max_length": 100}},
{Key: "symptom_trend", Name: "症状变化趋势", Type: "select", Required: true, Options: []string{"好转", "稳定", "反复", "加重", "不清楚"}},
{Key: "prior_visit", Name: "本次问题是否已就诊", Type: "boolean"},
{Key: "breathing_difficulty", Name: "是否呼吸困难", Type: "boolean", Required: true},
{Key: "active_bleeding", Name: "是否持续出血", Type: "boolean", Required: true},
{Key: "convulsion", Name: "是否抽搐或意识异常", Type: "boolean", Required: true},
{Key: "severe_trauma", Name: "是否遭遇严重外伤或高处坠落", Type: "boolean", Required: true},
{Key: "unable_to_urinate", Name: "是否无法排尿", Type: "boolean", Required: true},
{Key: "toxin_exposure", Name: "是否可能误食毒物或异物", Type: "boolean", Required: true},
{Key: "repeated_vomiting", Name: "是否持续呕吐或干呕", Type: "boolean", Required: true},
{Key: "severe_pain", Name: "是否剧烈疼痛或腹部明显膨大", Type: "boolean", Required: true},
{Key: "heatstroke_sign", Name: "是否疑似中暑或体温异常升高", Type: "boolean", Required: true},
{Key: "unable_to_stand", Name: "是否虚弱到无法站立", Type: "boolean", Required: true},
{Key: "has_emergency_sign", Name: "是否存在其他危及生命的表现", Type: "boolean", Required: true},
{Key: "appetite", Name: "食欲情况", Type: "select", Options: []string{"正常", "下降", "完全不吃"}},
{Key: "water_intake", Name: "饮水情况", Type: "select", Options: []string{"正常", "增多", "减少", "完全不喝", "不清楚"}},
{Key: "spirit_status", Name: "精神状态", Type: "select", Options: []string{"正常", "较差", "嗜睡或无法站立"}},
{Key: "vomiting", Name: "是否呕吐", Type: "boolean"},
{Key: "diarrhea", Name: "是否腹泻", Type: "boolean"},
{Key: "urination_status", Name: "排尿情况", Type: "select", Options: []string{"正常", "增多", "减少", "频繁少量", "带血", "不清楚"}},
{Key: "stool_status", Name: "排便情况", Type: "select", Options: []string{"正常", "稀软", "水样", "带血或黑便", "便秘", "不清楚"}},
{Key: "measured_temperature", Name: "实测体温(℃)", Type: "number", Validation: map[string]interface{}{"min": 30, "max": 45}},
{Key: "chronic_disease_history", Name: "既往疾病及手术史", Type: "textarea", Validation: map[string]interface{}{"max_length": 1000}},
{Key: "vaccination_status", Name: "免疫情况", Type: "select", Options: []string{"按期完成", "未完成", "逾期", "不清楚", "不适用"}},
{Key: "medication_history", Name: "近期用药、驱虫药和保健品", Type: "textarea", Validation: map[string]interface{}{"max_length": 1000}},
{Key: "allergy_history", Name: "已知药物过敏或不良反应史", Type: "textarea", Validation: map[string]interface{}{"max_length": 500}},
{Key: "wants_medication", Name: "是否咨询具体用药", Type: "boolean", Required: true},
{Key: "medication_request_type", Name: "用药咨询类型", Type: "select", Options: []string{"新症状想用药", "询问现有处方用法", "续方或复购", "漏服或多服", "服药后异常", "其他"}},
{Key: "requested_medication", Name: "咨询的药品名称及规格", Type: "text", Validation: map[string]interface{}{"max_length": 200}},
{Key: "prescribed_by_vet", Name: "是否由宠物医生开具", Type: "boolean"},
{Key: "has_taken_medication", Name: "是否已经使用该药", Type: "boolean"},
{Key: "dose_and_time", Name: "已用剂量、次数及最后用药时间", Type: "textarea", Validation: map[string]interface{}{"max_length": 500}},
{Key: "human_medication_exposure", Name: "是否使用或误食人用药", Type: "boolean", Required: true},
{Key: "suspected_overdose", Name: "是否可能过量或重复用药", Type: "boolean", Required: true},
{Key: "adverse_reaction", Name: "用药后是否出现异常反应", Type: "boolean", Required: true},
{Key: "adverse_reaction_detail", Name: "异常反应描述", Type: "textarea", Validation: map[string]interface{}{"max_length": 1000}},
}
}
func seedKnowledgeCards(tx *gorm.DB, tenantID, userID, scenarioID uint64) (map[string]uint64, error) { func seedKnowledgeCards(tx *gorm.DB, tenantID, userID, scenarioID uint64) (map[string]uint64, error) {
definitions := map[string]map[string]interface{}{ definitions := map[string]map[string]interface{}{
"线上问诊边界": {
"standard_copy": "线上沟通用于收集信息和判断紧急程度,不能替代体格检查、检验和影像检查。医生需要结合完整病史及检查结果后才能给出诊断和治疗方案。",
"forbidden_copy": "不要仅凭文字、照片或单个症状作出确定诊断,也不要承诺某种处理一定有效。",
"risk_note": "信息不完整、症状持续加重或无法准确观察时,应建议尽快到院评估。",
},
"急症红旗征象": { "急症红旗征象": {
"standard_copy": "出现呼吸困难、持续出血、抽搐或意识异常、无法排尿、疑似误食毒物或异物等情况时,应立即建议就近急诊或转诊,并提前联系接诊机构。", "standard_copy": "出现呼吸困难、持续出血、抽搐或意识异常、严重外伤、无法排尿、持续呕吐或干呕、剧烈疼痛、腹部明显膨大、疑似中暑、无法站立、误食毒物或异物等情况时,应立即建议就近急诊或转诊,并提前联系接诊机构。",
"forbidden_copy": "不要承诺在家观察一定安全;不要在线给出能够替代急诊检查的判断。", "forbidden_copy": "不要承诺在家观察一定安全;不要在线给出能够替代急诊检查的判断。",
"risk_note": "急症分支优先级最高,不得因继续询问常规病史而延误就医。", "risk_note": "急症分支优先级最高,不得因继续询问常规病史而延误就医。",
}, },
@@ -180,6 +217,11 @@ func seedKnowledgeCards(tx *gorm.DB, tenantID, userID, scenarioID uint64) (map[s
"forbidden_copy": "未完成医生评估前,不给出具体处方药名称、剂量和疗程承诺。", "forbidden_copy": "未完成医生评估前,不给出具体处方药名称、剂量和疗程承诺。",
"risk_note": "对乙酰氨基酚、布洛芬等常见人用药可能对宠物造成严重伤害;如已误服,应按急症处理。", "risk_note": "对乙酰氨基酚、布洛芬等常见人用药可能对宠物造成严重伤害;如已误服,应按急症处理。",
}, },
"观察与复诊提示": {
"standard_copy": "请按医生要求记录精神、食欲、饮水、排尿、排便、呕吐次数及症状变化。若症状加重、出现新的急症表现,或在医生建议的观察时间内未改善,应尽快复诊。",
"forbidden_copy": "不要用固定天数替代医生根据病情给出的复诊时间,也不要因一次短暂好转自行停药。",
"risk_note": "离开本次沟通前,应让宠物主人复述下一步安排和需要立即就医的触发条件。",
},
} }
result := make(map[string]uint64, len(definitions)) result := make(map[string]uint64, len(definitions))
for title, content := range definitions { for title, content := range definitions {
@@ -194,11 +236,7 @@ func seedKnowledgeCards(tx *gorm.DB, tenantID, userID, scenarioID uint64) (map[s
return nil, err return nil, err
} }
contentJSON, _ := json.Marshal(content) contentJSON, _ := json.Marshal(content)
var version model.KnowledgeCardVersion if err := publishSeedKnowledgeVersion(tx, tenantID, card.ID, contentJSON); err != nil {
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) return nil, fmt.Errorf("seed knowledge card version %s: %w", title, err)
} }
result[title] = card.ID result[title] = card.ID
@@ -206,6 +244,42 @@ func seedKnowledgeCards(tx *gorm.DB, tenantID, userID, scenarioID uint64) (map[s
return result, nil return result, nil
} }
func publishSeedKnowledgeVersion(tx *gorm.DB, tenantID, cardID uint64, contentJSON []byte) error {
var latest model.KnowledgeCardVersion
err := tx.Where("knowledge_card_id = ? AND tenant_id = ?", cardID, tenantID).Order("version DESC").First(&latest).Error
if err == gorm.ErrRecordNotFound {
return tx.Create(&model.KnowledgeCardVersion{
TenantID: tenantID, KnowledgeCardID: cardID, Version: 1,
Content: datatypes.JSON(contentJSON), Status: "published",
}).Error
}
if err != nil {
return err
}
if sameJSON(latest.Content, contentJSON) {
if err := tx.Model(&model.KnowledgeCardVersion{}).Where("knowledge_card_id = ? AND tenant_id = ? AND id <> ? AND status = ?", cardID, tenantID, latest.ID, "published").Update("status", "superseded").Error; err != nil {
return err
}
return tx.Model(&latest).Update("status", "published").Error
}
if err := tx.Model(&model.KnowledgeCardVersion{}).Where("knowledge_card_id = ? AND tenant_id = ? AND status = ?", cardID, tenantID, "published").Update("status", "superseded").Error; err != nil {
return err
}
return tx.Create(&model.KnowledgeCardVersion{
TenantID: tenantID, KnowledgeCardID: cardID, Version: latest.Version + 1,
Content: datatypes.JSON(contentJSON), Status: "published",
}).Error
}
func sameJSON(left, right []byte) bool {
var leftValue interface{}
var rightValue interface{}
if json.Unmarshal(left, &leftValue) != nil || json.Unmarshal(right, &rightValue) != nil {
return false
}
return reflect.DeepEqual(leftValue, rightValue)
}
func seedSOP(tx *gorm.DB, tenantID, userID, scenarioID uint64, knowledgeIDs map[string]uint64) error { func seedSOP(tx *gorm.DB, tenantID, userID, scenarioID uint64, knowledgeIDs map[string]uint64) error {
var sop model.SOP var sop model.SOP
err := tx.Where("tenant_id = ? AND scenario_id = ? AND name = ?", tenantID, scenarioID, "宠物问诊问药标准 SOP").FirstOrCreate(&sop, model.SOP{ err := tx.Where("tenant_id = ? AND scenario_id = ? AND name = ?", tenantID, scenarioID, "宠物问诊问药标准 SOP").FirstOrCreate(&sop, model.SOP{
@@ -215,7 +289,7 @@ func seedSOP(tx *gorm.DB, tenantID, userID, scenarioID uint64, knowledgeIDs map[
return fmt.Errorf("find or create SOP: %w", err) return fmt.Errorf("find or create SOP: %w", err)
} }
if err := tx.Model(&sop).Updates(map[string]interface{}{ if err := tx.Model(&sop).Updates(map[string]interface{}{
"description": "从基本信息和主诉采集开始,优先筛查急症,再补充病史并给出安全的下一步指引。", "description": "标准化采集宠物主诉和病史,通过急症及用药风险双重分流,形成医生评估、转诊和复诊闭环。",
"status": "published", "status": "published",
}).Error; err != nil { }).Error; err != nil {
return err return err
@@ -223,7 +297,7 @@ 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 n.node_key = ? AND JSON_UNQUOTE(JSON_EXTRACT(n.config, '$.seed_key')) = ?", "v.sop_id = ? AND n.node_key = ? AND JSON_UNQUOTE(JSON_EXTRACT(n.config, '$.seed_key')) = ?",
sop.ID, "start", "pet-doctor-v2", sop.ID, "start", "pet-doctor-v5",
).Count(&seededPublished).Error; err != nil { ).Count(&seededPublished).Error; err != nil {
return err return err
} }
@@ -246,7 +320,7 @@ func seedSOP(tx *gorm.DB, tenantID, userID, scenarioID uint64, knowledgeIDs map[
return err return err
} }
nodes := petNodes(knowledgeIDs["问诊用药安全原则"]) nodes := petNodes(knowledgeIDs)
edges := petEdges() edges := petEdges()
if err := tx.Where("sop_version_id = ?", version.ID).Delete(&model.SOPEdge{}).Error; err != nil { if err := tx.Where("sop_version_id = ?", version.ID).Delete(&model.SOPEdge{}).Error; err != nil {
return err return err
@@ -274,6 +348,9 @@ func seedSOP(tx *gorm.DB, tenantID, userID, scenarioID uint64, knowledgeIDs map[
return fmt.Errorf("create edge %s -> %s: %w", definition.Source, definition.Target, err) return fmt.Errorf("create edge %s -> %s: %w", definition.Source, definition.Target, err)
} }
} }
if err := sopservice.BindKnowledgeVersions(tx, tenantID, version.ID); err != nil {
return fmt.Errorf("bind knowledge versions: %w", err)
}
now := time.Now() 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 { if err := tx.Model(&model.SOPVersion{}).Where("sop_id = ? AND id <> ? AND status = ?", sop.ID, version.ID, "published").Update("status", "superseded").Error; err != nil {
@@ -290,20 +367,29 @@ func seedSOP(tx *gorm.DB, tenantID, userID, scenarioID uint64, knowledgeIDs map[
}).Error }).Error
} }
func petNodes(knowledgeCardID uint64) []nodeDefinition { func petNodes(knowledgeIDs map[string]uint64) []nodeDefinition {
return []nodeDefinition{ return []nodeDefinition{
{Key: "start", Type: "start", Title: "开始", Config: map[string]interface{}{"seed_key": "pet-doctor-v2"}}, {Key: "start", Type: "start", Title: "开始", Config: map[string]interface{}{"seed_key": "pet-doctor-v5"}},
{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: "consultation_context", Type: "form", Title: "确认咨询背景", Content: "请确认本次咨询渠道和主要目的,便于医生判断沟通重点。", Config: map[string]interface{}{"field_keys": []string{"consultation_channel", "visit_goal"}}},
{Key: "chief_complaint", Type: "form", Title: "记录主诉", Content: "请让宠物主人按时间顺序描述最主要的症状和持续时间。", Config: map[string]interface{}{"field_keys": []string{"symptom", "symptom_duration"}}}, {Key: "basic_info", Type: "form", Title: "采集宠物基本信息", Content: "请确认宠物名称、种类、品种、年龄、体重、性别、绝育以及妊娠或哺乳情况;不确定的信息可以留空。", Config: map[string]interface{}{"field_keys": []string{"pet_name", "pet_type", "pet_breed", "pet_age", "pet_weight", "pet_sex", "is_neutered", "pregnancy_status"}}},
{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: "chief_complaint", Type: "form", Title: "记录主诉与变化", Content: "请让宠物主人按时间顺序描述主要症状、开始时间、变化趋势,以及是否已经就诊。", Config: map[string]interface{}{"field_keys": []string{"symptom", "symptom_duration", "symptom_trend", "prior_visit"}}},
{Key: "emergency_screen", Type: "form", Title: "筛查急症红旗", Content: "请逐项询问,不要仅凭主人主动描述判断。任意一项为“是”都应停止常规问诊并优先转诊。", Config: map[string]interface{}{"field_keys": []string{"breathing_difficulty", "active_bleeding", "convulsion", "severe_trauma", "unable_to_urinate", "toxin_exposure", "repeated_vomiting", "severe_pain", "heatstroke_sign", "unable_to_stand", "has_emergency_sign"}}},
{Key: "emergency_check", Type: "condition", Title: "判断是否需要立即转诊", Content: "系统根据急症筛查结果自动分流。", Config: map[string]interface{}{"risk_level": "high"}}, {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_guidance", Type: "knowledge", Title: "告知急症处置原则", Content: "请按知识卡向宠物主人说明急症风险和立即就医要求。", Config: map[string]interface{}{"knowledge_card_id": knowledgeIDs["急症红旗征象"]}},
{Key: "history_collection", Type: "form", Title: "补充状态与病史", Content: "继续了解食欲、精神状态、消化道表现、近期用药和药物过敏史。", Config: map[string]interface{}{"field_keys": []string{"appetite", "spirit_status", "vomiting", "diarrhea", "medication_history", "allergy_history"}}}, {Key: "emergency_escalate", Type: "escalate", Title: "立即急诊或转诊", Content: "请停止常规线上问诊,建议立即前往最近的宠物急诊并提前联系接诊机构。保持宠物安静和通风,除接诊医生明确指导外,不要自行喂药、催吐或强行喂食,并携带可疑药物、毒物包装及既往资料。", Config: map[string]interface{}{"action": "urgent_referral"}},
{Key: "medication_intent", Type: "question", Title: "确认用药诉求", Content: "请确认宠物主人是否正在咨询具体药物、剂量或疗程。", Config: map[string]interface{}{"field_key": "wants_medication", "required": true}}, {Key: "current_status", Type: "form", Title: "补充当前生命状态", Content: "继续了解食欲、饮水、精神、呕吐、腹泻、排尿、排便和实测体温。未测体温时请留空,不要凭触摸估计。", Config: map[string]interface{}{"field_keys": []string{"appetite", "water_intake", "spirit_status", "vomiting", "diarrhea", "urination_status", "stool_status", "measured_temperature"}}},
{Key: "medication_safety", Type: "knowledge", Title: "说明用药安全原则", Content: "用药需要结合物种、年龄、体重、既往病史和必要检查,由宠物医生评估后确定。请勿自行使用人用药、增加剂量或混用药物。", Config: map[string]interface{}{"knowledge_card_id": knowledgeCardID}}, {Key: "medical_history", Type: "form", Title: "补充既往史", Content: "请记录既往疾病和手术、免疫情况、近期所有药物及保健品,以及已知药物过敏或不良反应。", Config: map[string]interface{}{"field_keys": []string{"chronic_disease_history", "vaccination_status", "medication_history", "allergy_history"}}},
{Key: "assessment", Type: "message", Title: "提交医生评估", Content: "信息已记录。请由宠物医生结合体格检查和必要检验判断病因及治疗方案;如症状加重或出现新的急症表现,应立即就医。", Config: map[string]interface{}{}}, {Key: "medication_intent", Type: "question", Title: "确认用药诉求", Content: "请确认宠物主人是否正在咨询具体药物、剂量、疗程、续方、漏服或用药后异常。", Config: map[string]interface{}{"field_key": "wants_medication", "required": true}},
{Key: "finish", Type: "finish", Title: "完成问诊记录", Content: "本次初步问诊信息已完整记录,请按医生建议安排复诊、检查或治疗。", Config: map[string]interface{}{}}, {Key: "medication_details", Type: "form", Title: "采集用药详情", Content: "请核对药名和规格、咨询类型、是否由宠物医生开具、是否已经使用,以及已用剂量和时间。无法确认时请查看处方或药品包装,不要猜测。", Config: map[string]interface{}{"field_keys": []string{"medication_request_type", "requested_medication", "prescribed_by_vet", "has_taken_medication", "dose_and_time"}}},
{Key: "medication_risk_screen", Type: "form", Title: "筛查用药风险", Content: "请确认是否涉及人用药、可能过量或重复用药,以及用药后异常;如有异常,请记录出现时间和具体表现。", Config: map[string]interface{}{"field_keys": []string{"human_medication_exposure", "suspected_overdose", "adverse_reaction", "adverse_reaction_detail"}}},
{Key: "medication_risk_check", Type: "condition", Title: "判断是否存在用药急症风险", Content: "系统根据用药风险信息自动分流。", Config: map[string]interface{}{"risk_level": "high"}},
{Key: "medication_escalate", Type: "escalate", Title: "立即转医生或急诊评估", Content: "存在误用人用药、可能过量或用药后异常。请立即转宠物医生评估;如出现呼吸困难、意识异常、抽搐、虚脱或症状快速加重,应立即前往急诊。请保留药品包装、处方和实际用药时间记录。", Config: map[string]interface{}{"action": "medication_risk_referral"}},
{Key: "medication_safety", Type: "knowledge", Title: "说明用药安全原则", Content: "请按知识卡说明用药边界,在医生完成评估前不要承诺具体药名、剂量或疗程。", Config: map[string]interface{}{"knowledge_card_id": knowledgeIDs["问诊用药安全原则"]}},
{Key: "consultation_boundary", Type: "knowledge", Title: "说明线上问诊边界", Content: "请说明线上沟通的作用和限制,并确认宠物主人理解下一步需要医生评估。", Config: map[string]interface{}{"knowledge_card_id": knowledgeIDs["线上问诊边界"]}},
{Key: "assessment", Type: "message", Title: "提交医生评估并明确下一步", Content: "信息已记录。请由宠物医生结合病史、体格检查和必要检验判断后续方案;向宠物主人明确是立即到院、预约就诊、按原处方处理还是居家观察,并记录医生给出的时间要求。", Config: map[string]interface{}{}},
{Key: "safety_net", Type: "knowledge", Title: "确认观察和复诊提示", Content: "请按知识卡说明观察指标、复诊时间和立即就医触发条件,并请宠物主人复述确认。", Config: map[string]interface{}{"knowledge_card_id": knowledgeIDs["观察与复诊提示"]}},
{Key: "finish", Type: "finish", Title: "完成问诊记录", Content: "本次初步问诊信息已记录,请按宠物医生给出的时间和方式安排检查、复诊或治疗;情况变化时及时重新联系或立即就医。", Config: map[string]interface{}{}},
} }
} }
@@ -312,22 +398,41 @@ func petEdges() []edgeDefinition {
map[string]interface{}{"field": "breathing_difficulty", "operator": "equals", "value": true}, map[string]interface{}{"field": "breathing_difficulty", "operator": "equals", "value": true},
map[string]interface{}{"field": "active_bleeding", "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": "convulsion", "operator": "equals", "value": true},
map[string]interface{}{"field": "severe_trauma", "operator": "equals", "value": true},
map[string]interface{}{"field": "unable_to_urinate", "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": "toxin_exposure", "operator": "equals", "value": true},
map[string]interface{}{"field": "repeated_vomiting", "operator": "equals", "value": true},
map[string]interface{}{"field": "severe_pain", "operator": "equals", "value": true},
map[string]interface{}{"field": "heatstroke_sign", "operator": "equals", "value": true},
map[string]interface{}{"field": "unable_to_stand", "operator": "equals", "value": true},
map[string]interface{}{"field": "has_emergency_sign", "operator": "equals", "value": true}, map[string]interface{}{"field": "has_emergency_sign", "operator": "equals", "value": true},
} }
medicationRiskRules := []interface{}{
map[string]interface{}{"field": "human_medication_exposure", "operator": "equals", "value": true},
map[string]interface{}{"field": "suspected_overdose", "operator": "equals", "value": true},
map[string]interface{}{"field": "adverse_reaction", "operator": "equals", "value": true},
}
return []edgeDefinition{ return []edgeDefinition{
{Source: "start", Target: "opening", Condition: map[string]interface{}{}}, {Source: "start", Target: "opening", Condition: map[string]interface{}{}},
{Source: "opening", Target: "basic_info", Condition: map[string]interface{}{}}, {Source: "opening", Target: "consultation_context", Condition: map[string]interface{}{}},
{Source: "consultation_context", Target: "basic_info", Condition: map[string]interface{}{}},
{Source: "basic_info", Target: "chief_complaint", 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: "chief_complaint", Target: "emergency_screen", Condition: map[string]interface{}{}},
{Source: "emergency_screen", Target: "emergency_check", 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: "emergency_guidance", Condition: map[string]interface{}{"any": emergencyRules}, Priority: 0},
{Source: "emergency_check", Target: "history_collection", Condition: map[string]interface{}{}, Priority: 100}, {Source: "emergency_check", Target: "current_status", Condition: map[string]interface{}{}, Priority: 100},
{Source: "history_collection", Target: "medication_intent", Condition: map[string]interface{}{}}, {Source: "emergency_guidance", Target: "emergency_escalate", Condition: map[string]interface{}{}},
{Source: "medication_intent", Target: "medication_safety", Condition: map[string]interface{}{"field": "wants_medication", "operator": "equals", "value": true}, Priority: 0}, {Source: "current_status", Target: "medical_history", Condition: map[string]interface{}{}},
{Source: "medication_intent", Target: "assessment", Condition: map[string]interface{}{}, Priority: 100}, {Source: "medical_history", Target: "medication_intent", Condition: map[string]interface{}{}},
{Source: "medication_safety", Target: "assessment", Condition: map[string]interface{}{}}, {Source: "medication_intent", Target: "medication_details", Condition: map[string]interface{}{"field": "wants_medication", "operator": "equals", "value": true}, Priority: 0},
{Source: "assessment", Target: "finish", Condition: map[string]interface{}{}}, {Source: "medication_intent", Target: "consultation_boundary", Condition: map[string]interface{}{}, Priority: 100},
{Source: "medication_details", Target: "medication_risk_screen", Condition: map[string]interface{}{}},
{Source: "medication_risk_screen", Target: "medication_risk_check", Condition: map[string]interface{}{}},
{Source: "medication_risk_check", Target: "medication_escalate", Condition: map[string]interface{}{"any": medicationRiskRules}, Priority: 0},
{Source: "medication_risk_check", Target: "medication_safety", Condition: map[string]interface{}{}, Priority: 100},
{Source: "medication_safety", Target: "consultation_boundary", Condition: map[string]interface{}{}},
{Source: "consultation_boundary", Target: "assessment", Condition: map[string]interface{}{}},
{Source: "assessment", Target: "safety_net", Condition: map[string]interface{}{}},
{Source: "safety_net", Target: "finish", Condition: map[string]interface{}{}},
} }
} }

View File

@@ -0,0 +1,113 @@
package main
import (
"encoding/json"
"testing"
"git.iwork-ai.com/xdc/iqudo-top1/internal/model"
"git.iwork-ai.com/xdc/iqudo-top1/internal/sop"
"gorm.io/datatypes"
)
func TestPetDoctorSOPPassesPublishValidation(t *testing.T) {
fields := petFieldModels()
knowledgeIDs := map[string]uint64{
"线上问诊边界": 1,
"急症红旗征象": 2,
"问诊用药安全原则": 3,
"观察与复诊提示": 4,
}
nodes := petNodeModels(petNodes(knowledgeIDs))
edges := petEdgeModels(petEdges())
if len(fields) != 47 || len(nodes) != 21 || len(edges) != 21 {
t.Fatalf("unexpected definition size: fields=%d nodes=%d edges=%d", len(fields), len(nodes), len(edges))
}
problems := sop.ValidateForPublish("start", nodes, edges, sop.ValidationContext{
Fields: fields,
PublishedKnowledgeCardIDs: map[uint64]bool{
1: true,
2: true,
3: true,
4: true,
},
})
if len(problems) > 0 {
t.Fatalf("pet doctor SOP should be publishable, got: %v", problems)
}
}
func TestPetDoctorSOPHasBothRiskEscalations(t *testing.T) {
nodes := petNodes(map[string]uint64{
"线上问诊边界": 1,
"急症红旗征象": 2,
"问诊用药安全原则": 3,
"观察与复诊提示": 4,
})
escalations := map[string]bool{}
for _, node := range nodes {
if node.Type == "escalate" {
escalations[node.Key] = true
}
}
for _, key := range []string{"emergency_escalate", "medication_escalate"} {
if !escalations[key] {
t.Fatalf("missing risk escalation node %s", key)
}
}
}
func TestSameJSONIgnoresObjectOrderAndWhitespace(t *testing.T) {
left := []byte(`{"standard_copy":"安全提示","risk_note":"立即就医"}`)
right := []byte(`{ "risk_note": "立即就医", "standard_copy": "安全提示" }`)
if !sameJSON(left, right) {
t.Fatal("equivalent JSON objects should match")
}
}
func petFieldModels() []model.ScenarioField {
definitions := petFieldDefinitions()
fields := make([]model.ScenarioField, 0, len(definitions))
for _, definition := range definitions {
options, _ := json.Marshal(definition.Options)
validation, _ := json.Marshal(definition.Validation)
fields = append(fields, model.ScenarioField{
FieldKey: definition.Key,
FieldName: definition.Name,
FieldType: definition.Type,
Required: definition.Required,
Options: datatypes.JSON(options),
Validation: datatypes.JSON(validation),
})
}
return fields
}
func petNodeModels(definitions []nodeDefinition) []model.SOPNode {
nodes := make([]model.SOPNode, 0, len(definitions))
for _, definition := range definitions {
config, _ := json.Marshal(definition.Config)
nodes = append(nodes, model.SOPNode{
NodeKey: definition.Key,
Type: definition.Type,
Title: definition.Title,
Content: definition.Content,
Config: datatypes.JSON(config),
})
}
return nodes
}
func petEdgeModels(definitions []edgeDefinition) []model.SOPEdge {
edges := make([]model.SOPEdge, 0, len(definitions))
for _, definition := range definitions {
condition, _ := json.Marshal(definition.Condition)
edges = append(edges, model.SOPEdge{
SourceNodeKey: definition.Source,
TargetNodeKey: definition.Target,
Condition: datatypes.JSON(condition),
Priority: definition.Priority,
})
}
return edges
}

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:`M360 184h-8c4.4 0 8-3.6 8-8v8h304v-8c0 4.4 3.6 8 8 8h-8v72h72v-80c0-35.3-28.7-64-64-64H352c-35.3 0-64 28.7-64 64v80h72v-72zm504 72H160c-17.7 0-32 14.3-32 32v32c0 4.4 3.6 8 8 8h60.4l24.7 523c1.6 34.1 29.8 61 63.9 61h454c34.2 0 62.3-26.8 63.9-61l24.7-523H888c4.4 0 8-3.6 8-8v-32c0-17.7-14.3-32-32-32zM731.3 840H292.7l-24.2-512h487l-24.2 512z`}}]},name:`delete`,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=`DeleteOutlined`,a.inheritAttrs=!1;export{a as t};

File diff suppressed because one or more lines are too long

View File

@@ -1 +0,0 @@
.execution-shell[data-v-e2671b1e]{max-width:1220px}.sop-catalog[data-v-e2671b1e]{grid-template-columns:repeat(2,minmax(0,1fr));gap:14px;display:grid}.sop-entry[data-v-e2671b1e]{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-e2671b1e]:hover{border-color:#9ac8b8;box-shadow:0 8px 24px #20282512}.entry-seq[data-v-e2671b1e]{color:#8c9893;font-family:monospace;font-size:12px}.sop-entry small[data-v-e2671b1e]{color:var(--green);font-weight:650}.sop-entry h2[data-v-e2671b1e]{margin:8px 0 7px;font-family:Noto Serif SC,serif;font-size:20px}.sop-entry p[data-v-e2671b1e]{color:var(--muted);margin:0;line-height:1.6}.play[data-v-e2671b1e]{color:#fff;background:#202825;border-radius:4px;place-items:center;width:38px;height:38px;font-size:18px;display:grid}.run-workspace[data-v-e2671b1e]{grid-template-columns:270px minmax(0,1fr);gap:18px;display:grid}.run-context[data-v-e2671b1e]{color:#fff;background:#202825;border-radius:6px;align-self:start;padding:24px;position:sticky;top:86px}.run-label[data-v-e2671b1e]{color:#72c9ab;font-size:11px;font-weight:700}.run-context h2[data-v-e2671b1e]{margin:12px 0 8px;font-family:Noto Serif SC,serif}.run-context>p[data-v-e2671b1e]{color:#aebdb7;margin:0 0 28px;line-height:1.6}.context-meta[data-v-e2671b1e]{border-top:1px solid #3a4742;justify-content:space-between;align-items:center;padding:13px 0;display:flex}.context-meta span[data-v-e2671b1e]{color:#9eada7;font-size:12px}.privacy-note[data-v-e2671b1e]{color:#889b93;gap:8px;margin-top:28px;font-size:11px;display:flex}.conversation[data-v-e2671b1e]{min-height:590px;padding:30px 34px}.conversation-progress[data-v-e2671b1e]{color:#66736d;align-items:center;gap:8px;font-size:12px;display:flex}.conversation-progress span[data-v-e2671b1e]{background:#d08736;border-radius:50%;width:8px;height:8px;box-shadow:0 0 0 4px #faefe1}.conversation-progress span.done[data-v-e2671b1e]{background:var(--green);box-shadow:0 0 0 4px #e4f1ec}.node-content[data-v-e2671b1e]{padding:44px 0 26px}.node-content small[data-v-e2671b1e]{color:var(--green);font-size:10px;font-weight:800}.node-content h1[data-v-e2671b1e]{margin:8px 0 22px;font-family:Noto Serif SC,serif;font-size:28px}.node-content blockquote[data-v-e2671b1e]{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-e2671b1e]{max-width:680px}.choice-group[data-v-e2671b1e]{flex-wrap:wrap;display:flex}.run-actions[data-v-e2671b1e]{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-e2671b1e]{text-align:center;padding:40px 0}.completed-state>span[data-v-e2671b1e]{color:var(--green);font-size:50px}.completed-state h3[data-v-e2671b1e]{margin:14px 0 8px;font-family:Noto Serif SC,serif;font-size:24px}.completed-state p[data-v-e2671b1e]{color:var(--muted);margin:0 0 24px}.quick-feedback[data-v-e2671b1e]{text-align:left;border-top:1px solid var(--line);border-bottom:1px solid var(--line);background:#f5f8f6;grid-template-columns:180px 1fr auto;align-items:center;gap:12px;max-width:620px;margin:0 auto 18px;padding:16px;display:grid}.quick-feedback>span[data-v-e2671b1e]{font-size:12px;font-weight:650}.quick-feedback[data-v-e2671b1e] .ant-rate{font-size:18px}.quick-feedback[data-v-e2671b1e] .ant-input{grid-column:1/3}.feedback-thanks[data-v-e2671b1e]{max-width:520px;color:var(--green);background:#edf6f2;margin:0 auto 18px;padding:12px;font-size:12px}@media (width<=800px){.sop-catalog[data-v-e2671b1e],.run-workspace[data-v-e2671b1e]{grid-template-columns:1fr}.run-context[data-v-e2671b1e]{position:static}.conversation[data-v-e2671b1e]{padding:22px}.run-actions[data-v-e2671b1e]{margin:24px -22px -22px;padding:16px 22px}.run-actions span[data-v-e2671b1e]{display:none}.quick-feedback[data-v-e2671b1e]{grid-template-columns:1fr}.quick-feedback[data-v-e2671b1e] .ant-input{grid-column:auto}}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
.execution-shell[data-v-937f7610]{max-width:1220px}.sop-catalog[data-v-937f7610]{grid-template-columns:repeat(2,minmax(0,1fr));gap:14px;display:grid}.sop-entry[data-v-937f7610]{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-937f7610]:hover{border-color:#9ac8b8;box-shadow:0 8px 24px #20282512}.entry-seq[data-v-937f7610]{color:#8c9893;font-family:monospace;font-size:12px}.sop-entry small[data-v-937f7610]{color:var(--green);font-weight:650}.sop-entry h2[data-v-937f7610]{margin:8px 0 7px;font-family:Noto Serif SC,serif;font-size:20px}.sop-entry p[data-v-937f7610]{color:var(--muted);margin:0;line-height:1.6}.play[data-v-937f7610]{color:#fff;background:#202825;border-radius:4px;place-items:center;width:38px;height:38px;font-size:18px;display:grid}.run-workspace[data-v-937f7610]{grid-template-columns:270px minmax(0,1fr);gap:18px;display:grid}.run-context[data-v-937f7610]{color:#fff;background:#202825;border-radius:6px;align-self:start;padding:24px;position:sticky;top:86px}.run-label[data-v-937f7610]{color:#72c9ab;font-size:11px;font-weight:700}.run-context h2[data-v-937f7610]{margin:12px 0 8px;font-family:Noto Serif SC,serif}.run-context>p[data-v-937f7610]{color:#aebdb7;margin:0 0 28px;line-height:1.6}.context-meta[data-v-937f7610]{border-top:1px solid #3a4742;justify-content:space-between;align-items:center;padding:13px 0;display:flex}.context-meta span[data-v-937f7610]{color:#9eada7;font-size:12px}.privacy-note[data-v-937f7610]{color:#889b93;gap:8px;margin-top:28px;font-size:11px;display:flex}.conversation[data-v-937f7610]{min-height:590px;padding:30px 34px}.conversation-progress[data-v-937f7610]{color:#66736d;align-items:center;gap:8px;font-size:12px;display:flex}.conversation-progress span[data-v-937f7610]{background:#d08736;border-radius:50%;width:8px;height:8px;box-shadow:0 0 0 4px #faefe1}.conversation-progress span.done[data-v-937f7610]{background:var(--green);box-shadow:0 0 0 4px #e4f1ec}.node-content[data-v-937f7610]{padding:44px 0 26px}.node-content small[data-v-937f7610]{color:var(--green);font-size:10px;font-weight:800}.node-content h1[data-v-937f7610]{margin:8px 0 22px;font-family:Noto Serif SC,serif;font-size:28px}.node-content blockquote[data-v-937f7610]{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-937f7610]{max-width:680px}.choice-group[data-v-937f7610]{flex-wrap:wrap;display:flex}.run-actions[data-v-937f7610]{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}.run-actions>div[data-v-937f7610]{align-items:center;gap:9px;display:flex}.completed-state[data-v-937f7610]{text-align:center;padding:40px 0}.completed-state>span[data-v-937f7610]{color:var(--green);font-size:50px}.completed-state h3[data-v-937f7610]{margin:14px 0 8px;font-family:Noto Serif SC,serif;font-size:24px}.completed-state p[data-v-937f7610]{color:var(--muted);margin:0 0 24px}.quick-feedback[data-v-937f7610]{text-align:left;border-top:1px solid var(--line);border-bottom:1px solid var(--line);background:#f5f8f6;grid-template-columns:180px 1fr auto;align-items:center;gap:12px;max-width:620px;margin:0 auto 18px;padding:16px;display:grid}.quick-feedback>span[data-v-937f7610]{font-size:12px;font-weight:650}.quick-feedback[data-v-937f7610] .ant-rate{font-size:18px}.quick-feedback[data-v-937f7610] .ant-input{grid-column:1/3}.feedback-thanks[data-v-937f7610]{max-width:520px;color:var(--green);background:#edf6f2;margin:0 auto 18px;padding:12px;font-size:12px}.knowledge-snapshot[data-v-937f7610]{border-top:1px solid var(--line);border-bottom:1px solid var(--line);margin:0 0 24px}.knowledge-head[data-v-937f7610]{justify-content:space-between;align-items:center;padding:12px 0;display:flex}.knowledge-head span[data-v-937f7610]{color:var(--muted);font-size:11px}.standard-copy[data-v-937f7610],.knowledge-note[data-v-937f7610]{border-left:3px solid var(--green);background:#f2f7f5;padding:14px 16px}.standard-copy small[data-v-937f7610],.knowledge-note small[data-v-937f7610]{font-size:10px;font-weight:750}.standard-copy p[data-v-937f7610],.knowledge-note p[data-v-937f7610]{margin:6px 0 0;line-height:1.7}.knowledge-note[data-v-937f7610]{background:#fbf3f0;border-left-color:#c4573f;margin-top:8px}.knowledge-note.risk[data-v-937f7610]{background:#fbf6eb;border-left-color:#bd7b24}@media (width<=800px){.sop-catalog[data-v-937f7610],.run-workspace[data-v-937f7610]{grid-template-columns:1fr}.run-context[data-v-937f7610]{position:static}.conversation[data-v-937f7610]{padding:22px}.run-actions[data-v-937f7610]{margin:24px -22px -22px;padding:16px 22px}.run-actions span[data-v-937f7610]{display:none}.quick-feedback[data-v-937f7610]{grid-template-columns:1fr}.quick-feedback[data-v-937f7610] .ant-input{grid-column:auto}}

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 @@
import{A as e,Bt as t,F as n,I as r,O as i,P as a,Q as o,Tt as s,Y as c,_t as l,a as u,j as d,k as f,lt as p,n as m,r as h,t as g,tt as _,vt as v}from"./client-Bk7n1O1W.js";import{a as y}from"./config-provider-CxhqvDI_.js";import{n as b,r as x,t as S}from"./EyeOutlined-C1VmLQCG.js";import{h as C}from"./useApi-CROJJdhE-C8LJOf_8.js";var w={icon:{tag:`svg`,attrs:{viewBox:`64 64 896 896`,focusable:`false`},children:[{tag:`path`,attrs:{d:`M512 64C264.6 64 64 264.6 64 512s200.6 448 448 448 448-200.6 448-448S759.4 64 512 64zm0 820c-205.4 0-372-166.6-372-372 0-89 31.3-170.8 83.5-234.8l523.3 523.3C682.8 852.7 601 884 512 884zm288.5-137.2L277.2 223.5C341.2 171.3 423 140 512 140c205.4 0 372 166.6 372 372 0 89-31.3 170.8-83.5 234.8z`}}]},name:`stop`,theme:`outlined`};function T(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){E(e,t,n[t])})}return e}function E(e,t,n){return t in e?Object.defineProperty(e,t,{value:n,enumerable:!0,configurable:!0,writable:!0}):e[t]=n,e}var D=function(e,t){var r=T({},e,t.attrs);return n(u,T({},r,{icon:w}),null)};D.displayName=`StopOutlined`,D.inheritAttrs=!1;var O={class:`page-shell review-page`},k={class:`page-heading`},A={class:`queue-count`},j={class:`surface review-table`},M={key:0,class:`sop-cell`},N={key:2,class:`waiting`},P={key:3,class:`row-actions`},F={class:`review-empty`},I={class:`modal-copy`},L=h(r({__name:`ReviewCenterView`,setup(r){let u=C(),h=v(!1),w=v([]),T=v(null),E=v(!1),L=l({reason:``}),R=[{title:`SOP / 场景`,key:`sop`},{title:`版本`,dataIndex:`version`,width:90},{title:`提交人`,dataIndex:`creator_name`,width:140},{title:`等待时间`,dataIndex:`submitted_at`,width:180},{title:``,key:`action`,width:250}];async function z(){h.value=!0;try{let e=await g.get(`/reviews`);w.value=e.items||[]}catch(e){y.error(m(e))}finally{h.value=!1}}async function B(e){E.value=!0;try{await g.post(`/sops/${e.sop_id}/publish`),y.success(`${e.sop_name}”已通过并发布`),await z()}catch(e){y.error(m(e))}finally{E.value=!1}}function V(e){T.value=e,L.reason=``}async function H(){if(!T.value||!L.reason.trim())return y.warning(`请填写退回原因`);E.value=!0;try{await g.post(`/sops/${T.value.sop_id}/reject`,{reason:L.reason}),y.success(`已退回编辑者修改`),T.value=null,await z()}catch(e){y.error(m(e))}finally{E.value=!1}}function U(e){let t=Math.max(1,Math.round((Date.now()-new Date(e).getTime())/6e4));if(t<60)return`${t} 分钟`;let n=Math.round(t/60);return n<48?`${n} 小时`:`${Math.round(n/24)}`}return c(z),(r,c)=>{let l=_(`a-tag`),m=_(`a-button`),g=_(`a-table`),v=_(`a-textarea`),y=_(`a-modal`);return o(),d(`div`,O,[i(`div`,k,[c[3]||=i(`div`,null,[i(`h1`,null,`审核中心`),i(`p`,null,`检查流程完整性、风险分支和话术口径后再决定发布。`)],-1),i(`div`,A,[n(s(b)),i(`b`,null,t(w.value.length),1),c[2]||=i(`span`,null,`待审核`,-1)])]),i(`section`,j,[c[9]||=i(`div`,{class:`review-guideline`},[i(`span`,null,`审核关注`),i(`b`,null,`字段完整`),i(`i`),i(`b`,null,`条件可达`),i(`i`),i(`b`,null,`高风险有出口`),i(`i`),i(`b`,null,`知识卡已发布`)],-1),n(g,{"row-key":`version_id`,loading:h.value,"data-source":w.value,columns:R,pagination:!1,scroll:{x:850}},{bodyCell:p(({column:r,record:h})=>[r.key===`sop`?(o(),d(`div`,M,[i(`b`,null,t(h.sop_name),1),i(`span`,null,t(h.scenario_name)+` · `+t(h.description||`暂无说明`),1)])):r.dataIndex===`version`?(o(),f(l,{key:1},{default:p(()=>[a(`V`+t(h.version),1)]),_:2},1024)):r.dataIndex===`submitted_at`?(o(),d(`span`,N,t(U(h.submitted_at)),1)):r.key===`action`?(o(),d(`div`,P,[n(m,{onClick:e=>s(u).push(`/sops/${h.sop_id}`)},{default:p(()=>[n(s(S)),c[4]||=a(`查看`,-1)]),_:1},8,[`onClick`]),n(m,{danger:``,onClick:e=>V(h)},{default:p(()=>[n(s(D)),c[5]||=a(`退回`,-1)]),_:1},8,[`onClick`]),n(m,{type:`primary`,loading:E.value,onClick:e=>B(h)},{default:p(()=>[n(s(x)),c[6]||=a(`通过`,-1)]),_:1},8,[`loading`,`onClick`])])):e(``,!0)]),emptyText:p(()=>[i(`div`,F,[n(s(x)),c[7]||=i(`b`,null,`审核队列已清空`,-1),c[8]||=i(`span`,null,`新提交的 SOP 会出现在这里。`,-1)])]),_:1},8,[`loading`,`data-source`])]),n(y,{open:!!T.value,title:`退回修改`,"ok-text":`确认退回`,"confirm-loading":E.value,onOk:H,onCancel:c[1]||=e=>T.value=null},{default:p(()=>[i(`p`,I,`请说明“`+t(T.value?.sop_name)+`”需要调整的具体内容,编辑者可据此修改后重新提交。`,1),n(v,{value:L.reason,"onUpdate:value":c[0]||=e=>L.reason=e,rows:5,maxlength:1e3,"show-count":``,placeholder:`例如:急症判断缺少默认路径,请补充非急症分支。`},null,8,[`value`])]),_:1},8,[`open`,`confirm-loading`])])}}}),[[`__scopeId`,`data-v-88d0e1a8`]]);export{L as default};

View File

@@ -0,0 +1 @@
import{A as e,Bt as t,F as n,I as r,O as i,P as a,Q as o,Tt as s,Y as c,_t as l,j as u,k as d,lt as f,n as p,r as m,t as h,tt as g,vt as _}from"./client-Bk7n1O1W.js";import{a as v}from"./config-provider-CxhqvDI_.js";import{n as y,r as b,t as x}from"./EyeOutlined-C1VmLQCG.js";import{t as S}from"./StopOutlined-X-yne_Zl.js";import{h as C}from"./useApi-CROJJdhE-C8LJOf_8.js";var w={class:`page-shell review-page`},T={class:`page-heading`},E={class:`queue-count`},D={class:`surface review-table`},O={key:0,class:`sop-cell`},k={key:2,class:`waiting`},A={key:3,class:`row-actions`},j={class:`review-empty`},M={class:`modal-copy`},N=m(r({__name:`ReviewCenterView`,setup(r){let m=C(),N=_(!1),P=_([]),F=_(null),I=_(!1),L=l({reason:``}),R=[{title:`SOP / 场景`,key:`sop`},{title:`版本`,dataIndex:`version`,width:90},{title:`提交人`,dataIndex:`creator_name`,width:140},{title:`等待时间`,dataIndex:`submitted_at`,width:180},{title:``,key:`action`,width:250}];async function z(){N.value=!0;try{let e=await h.get(`/reviews`);P.value=e.items||[]}catch(e){v.error(p(e))}finally{N.value=!1}}async function B(e){I.value=!0;try{await h.post(`/sops/${e.sop_id}/publish`),v.success(`${e.sop_name}”已通过并发布`),await z()}catch(e){v.error(p(e))}finally{I.value=!1}}function V(e){F.value=e,L.reason=``}async function H(){if(!F.value||!L.reason.trim())return v.warning(`请填写退回原因`);I.value=!0;try{await h.post(`/sops/${F.value.sop_id}/reject`,{reason:L.reason}),v.success(`已退回编辑者修改`),F.value=null,await z()}catch(e){v.error(p(e))}finally{I.value=!1}}function U(e){let t=Math.max(1,Math.round((Date.now()-new Date(e).getTime())/6e4));if(t<60)return`${t} 分钟`;let n=Math.round(t/60);return n<48?`${n} 小时`:`${Math.round(n/24)}`}return c(z),(r,c)=>{let l=g(`a-tag`),p=g(`a-button`),h=g(`a-table`),_=g(`a-textarea`),v=g(`a-modal`);return o(),u(`div`,w,[i(`div`,T,[c[3]||=i(`div`,null,[i(`h1`,null,`审核中心`),i(`p`,null,`检查流程完整性、风险分支和话术口径后再决定发布。`)],-1),i(`div`,E,[n(s(y)),i(`b`,null,t(P.value.length),1),c[2]||=i(`span`,null,`待审核`,-1)])]),i(`section`,D,[c[9]||=i(`div`,{class:`review-guideline`},[i(`span`,null,`审核关注`),i(`b`,null,`字段完整`),i(`i`),i(`b`,null,`条件可达`),i(`i`),i(`b`,null,`高风险有出口`),i(`i`),i(`b`,null,`知识卡已发布`)],-1),n(h,{"row-key":`version_id`,loading:N.value,"data-source":P.value,columns:R,pagination:!1,scroll:{x:850}},{bodyCell:f(({column:r,record:h})=>[r.key===`sop`?(o(),u(`div`,O,[i(`b`,null,t(h.sop_name),1),i(`span`,null,t(h.scenario_name)+` · `+t(h.description||`暂无说明`),1)])):r.dataIndex===`version`?(o(),d(l,{key:1},{default:f(()=>[a(`V`+t(h.version),1)]),_:2},1024)):r.dataIndex===`submitted_at`?(o(),u(`span`,k,t(U(h.submitted_at)),1)):r.key===`action`?(o(),u(`div`,A,[n(p,{onClick:e=>s(m).push(`/sops/${h.sop_id}`)},{default:f(()=>[n(s(x)),c[4]||=a(`查看`,-1)]),_:1},8,[`onClick`]),n(p,{danger:``,onClick:e=>V(h)},{default:f(()=>[n(s(S)),c[5]||=a(`退回`,-1)]),_:1},8,[`onClick`]),n(p,{type:`primary`,loading:I.value,onClick:e=>B(h)},{default:f(()=>[n(s(b)),c[6]||=a(`通过`,-1)]),_:1},8,[`loading`,`onClick`])])):e(``,!0)]),emptyText:f(()=>[i(`div`,j,[n(s(b)),c[7]||=i(`b`,null,`审核队列已清空`,-1),c[8]||=i(`span`,null,`新提交的 SOP 会出现在这里。`,-1)])]),_:1},8,[`loading`,`data-source`])]),n(v,{open:!!F.value,title:`退回修改`,"ok-text":`确认退回`,"confirm-loading":I.value,onOk:H,onCancel:c[1]||=e=>F.value=null},{default:f(()=>[i(`p`,M,`请说明“`+t(F.value?.sop_name)+`”需要调整的具体内容,编辑者可据此修改后重新提交。`,1),n(_,{value:L.reason,"onUpdate:value":c[0]||=e=>L.reason=e,rows:5,maxlength:1e3,"show-count":``,placeholder:`例如:急症判断缺少默认路径,请补充非急症分支。`},null,8,[`value`])]),_:1},8,[`open`,`confirm-loading`])])}}}),[[`__scopeId`,`data-v-88d0e1a8`]]);export{N as default};

File diff suppressed because one or more lines are too long

View File

@@ -1 +0,0 @@
.run-detail-page[data-v-65fb6a52]{max-width:1320px}.back-link[data-v-65fb6a52]{height:auto;margin:0 0 14px;padding:0}.detail-heading[data-v-65fb6a52]{justify-content:space-between;align-items:flex-end;gap:24px;margin-bottom:20px;display:flex}.run-code[data-v-65fb6a52]{color:var(--green);font-family:monospace;font-size:12px;font-weight:750}.detail-heading h1[data-v-65fb6a52]{margin:7px 0 5px;font-family:Noto Serif SC,serif;font-size:28px}.detail-heading p[data-v-65fb6a52]{color:var(--muted);margin:0}.result-block[data-v-65fb6a52]{border-left:3px solid;align-items:center;gap:12px;min-width:210px;padding:15px 17px;display:flex}.result-block>span[data-v-65fb6a52]:first-child{font-size:25px}.result-block>span[data-v-65fb6a52]:last-child{flex-direction:column;display:flex}.result-block small[data-v-65fb6a52]{font-size:10px}.result-block b[data-v-65fb6a52]{margin-top:3px}.result-block.success[data-v-65fb6a52]{color:#17664f;background:#eaf4f0;border-color:#25866a}.result-block.warning[data-v-65fb6a52]{color:#8d5b16;background:#fbf2e3;border-color:#c08026}.detail-layout[data-v-65fb6a52]{grid-template-columns:minmax(0,1fr) 330px;gap:16px;display:grid}.timeline-panel[data-v-65fb6a52]{min-height:560px;padding:22px 24px}.section-title[data-v-65fb6a52]{justify-content:space-between;align-items:baseline;gap:12px;margin-bottom:22px;display:flex}.section-title span[data-v-65fb6a52]{font-weight:700}.section-title small[data-v-65fb6a52]{color:var(--muted);font-size:11px}.run-timeline[data-v-65fb6a52]{padding-top:5px}.event-head[data-v-65fb6a52]{align-items:center;gap:9px;min-height:27px;display:flex}.event-head time[data-v-65fb6a52]{color:#8b9691;margin-left:auto;font-size:11px}.run-timeline p[data-v-65fb6a52]{color:#56635d;margin:8px 0 0;line-height:1.65}.event-answers[data-v-65fb6a52]{grid-template-columns:repeat(2,minmax(0,1fr));gap:7px;margin-top:10px;display:grid}.event-answers span[data-v-65fb6a52]{background:#f4f7f5;border-left:2px solid #a6cbbd;justify-content:space-between;gap:10px;padding:8px 10px;display:flex}.event-answers small[data-v-65fb6a52]{color:var(--muted)}.event-answers b[data-v-65fb6a52]{font-size:12px}.detail-aside[data-v-65fb6a52]{flex-direction:column;gap:16px;display:flex}.summary-panel[data-v-65fb6a52],.answer-panel[data-v-65fb6a52]{padding:20px}.summary-panel dl[data-v-65fb6a52]{margin:0}.summary-panel dl>div[data-v-65fb6a52]{border-bottom:1px solid #edf0ee;justify-content:space-between;gap:15px;padding:10px 0;display:flex}.summary-panel dl>div[data-v-65fb6a52]:last-child{border-bottom:0}.summary-panel dt[data-v-65fb6a52]{color:var(--muted);font-size:12px}.summary-panel dd[data-v-65fb6a52]{text-align:right;margin:0;font-size:12px}.answer-list>div[data-v-65fb6a52]{border-bottom:1px solid #edf0ee;flex-direction:column;padding:10px 0;display:flex}.answer-list>div[data-v-65fb6a52]:last-child{border-bottom:0}.answer-list span[data-v-65fb6a52]{color:var(--muted);font-size:11px}.answer-list b[data-v-65fb6a52]{margin-top:4px;font-size:13px}.aside-empty[data-v-65fb6a52]{color:#929d98;text-align:center;padding:24px 0;font-size:12px}.feedback-band[data-v-65fb6a52]{margin-top:16px;padding:22px 24px}.feedback-list[data-v-65fb6a52]{grid-template-columns:repeat(2,minmax(0,1fr));gap:16px;display:grid}.feedback-list article[data-v-65fb6a52]{border-top:1px solid var(--line);padding:14px 0}.feedback-list article>div[data-v-65fb6a52]{justify-content:space-between;align-items:center;display:flex}.feedback-list[data-v-65fb6a52] .ant-rate{font-size:15px}.feedback-list p[data-v-65fb6a52]{color:#54615b;margin:9px 0}.feedback-list time[data-v-65fb6a52]{color:#929d98;font-size:11px}.feedback-form[data-v-65fb6a52]{border-top:1px solid var(--line);grid-template-columns:220px minmax(0,1fr) auto;align-items:center;gap:14px;padding-top:16px;display:grid}.feedback-form>div[data-v-65fb6a52]{flex-direction:column;gap:6px;display:flex}.feedback-form>div>span[data-v-65fb6a52]{font-size:12px;font-weight:650}.feedback-form[data-v-65fb6a52] .ant-rate{font-size:20px}@media (width<=900px){.detail-layout[data-v-65fb6a52]{grid-template-columns:1fr}.detail-aside[data-v-65fb6a52]{grid-template-columns:1fr 1fr;display:grid}.feedback-form[data-v-65fb6a52],.feedback-list[data-v-65fb6a52]{grid-template-columns:1fr}}@media (width<=620px){.detail-heading[data-v-65fb6a52]{flex-direction:column;align-items:flex-start}.result-block[data-v-65fb6a52]{width:100%}.detail-aside[data-v-65fb6a52]{grid-template-columns:1fr}.timeline-panel[data-v-65fb6a52]{padding:18px}.event-head[data-v-65fb6a52]{flex-wrap:wrap;align-items:flex-start}.event-head time[data-v-65fb6a52]{width:100%;margin:0}.event-answers[data-v-65fb6a52]{grid-template-columns:1fr}}

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 @@
.history-toolbar[data-v-d3f4eec1]{border-bottom:1px solid var(--line);justify-content:space-between;align-items:center;gap:12px;min-height:58px;padding:10px 14px;display:flex}.filter-set[data-v-d3f4eec1]{color:#7a8781;flex-wrap:wrap;align-items:center;gap:9px;display:flex}.filter-set[data-v-d3f4eec1] .ant-select{width:150px}.filter-set[data-v-d3f4eec1] .ant-picker{width:240px}.run-sop[data-v-d3f4eec1]{flex-direction:column;display:flex}.run-sop small[data-v-d3f4eec1]{color:var(--muted);margin-top:4px}.surface[data-v-d3f4eec1] .ant-table-row{cursor:pointer}.surface[data-v-d3f4eec1] .ant-table-row:hover .row-arrow{color:var(--green);transform:translate(2px)}code[data-v-d3f4eec1]{color:#17654f;background:#eef5f2;border-radius:3px;padding:2px 5px}.row-arrow[data-v-d3f4eec1]{color:#9ba6a1;transition:all .16s}@media (width<=700px){.history-toolbar[data-v-d3f4eec1]{align-items:flex-start}.filter-set[data-v-d3f4eec1]{width:100%}.filter-set[data-v-d3f4eec1] .ant-select{width:calc(50% - 18px)}.filter-set[data-v-d3f4eec1] .ant-picker{width:100%}}

View File

@@ -1 +0,0 @@
.history-toolbar[data-v-681d0563]{border-bottom:1px solid var(--line);justify-content:space-between;align-items:center;gap:12px;min-height:58px;padding:10px 14px;display:flex}.filter-set[data-v-681d0563]{color:#7a8781;align-items:center;gap:9px;display:flex}.filter-set[data-v-681d0563] .ant-select{width:160px}.surface[data-v-681d0563] .ant-table-row{cursor:pointer}.surface[data-v-681d0563] .ant-table-row:hover .row-arrow{color:var(--green);transform:translate(2px)}code[data-v-681d0563]{color:#17654f;background:#eef5f2;border-radius:3px;padding:2px 5px}.row-arrow[data-v-681d0563]{color:#9ba6a1;transition:all .16s}@media (width<=600px){.history-toolbar[data-v-681d0563]{align-items:flex-start}.filter-set[data-v-681d0563]{flex-wrap:wrap}.filter-set[data-v-681d0563] .ant-select{width:138px}}

File diff suppressed because one or more lines are too long

View File

@@ -1 +0,0 @@
import{A as e,Bt as t,F as n,I as r,O as i,P as a,Q as o,S as s,Tt as c,Y as l,_t as u,a as d,j as f,k as p,lt as m,n as h,r as g,t as _,tt as v,vt as y}from"./client-Bk7n1O1W.js";import{a as b}from"./config-provider-CxhqvDI_.js";import{n as x,t as S}from"./ReloadOutlined-Cih9hKpY.js";import{h as C}from"./useApi-CROJJdhE-C8LJOf_8.js";var w={icon:{tag:`svg`,attrs:{viewBox:`64 64 896 896`,focusable:`false`},children:[{tag:`path`,attrs:{d:`M880.1 154H143.9c-24.5 0-39.8 26.7-27.5 48L349 597.4V838c0 17.7 14.2 32 31.8 32h262.4c17.6 0 31.8-14.3 31.8-32V597.4L907.7 202c12.2-21.3-3.1-48-27.6-48zM603.4 798H420.6V642h182.9v156zm9.6-236.6l-9.5 16.6h-183l-9.5-16.6L212.7 226h598.6L613 561.4z`}}]},name:`filter`,theme:`outlined`};function T(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){E(e,t,n[t])})}return e}function E(e,t,n){return t in e?Object.defineProperty(e,t,{value:n,enumerable:!0,configurable:!0,writable:!0}):e[t]=n,e}var D=function(e,t){var r=T({},e,t.attrs);return n(d,T({},r,{icon:w}),null)};D.displayName=`FilterOutlined`,D.inheritAttrs=!1;var O={class:`page-shell`},k={class:`surface`},A={class:`history-toolbar`},j={class:`filter-set`},M={key:0},N=g(r({__name:`RunHistoryView`,setup(r){let d=C(),g=y(!1),w=y([]),T=y(0),E=y(1),N=y(20),P=u({status:``,result:``}),F=[{title:`执行编号`,dataIndex:`id`,width:130},{title:`SOP`,dataIndex:`sop_name`},{title:`状态`,dataIndex:`status`,width:110},{title:`结果`,dataIndex:`result`,width:130},{title:`开始时间`,dataIndex:`started_at`,width:190},{title:`完成时间`,dataIndex:`completed_at`,width:190},{title:``,key:`action`,width:54}];async function I(e=!1){e&&(E.value=1),g.value=!0;try{let e=await _.get(`/runs`,{params:{page:E.value,page_size:N.value,status:P.status||void 0,result:P.result||void 0}});w.value=e.items||[],T.value=e.total}catch(e){b.error(h(e))}finally{g.value=!1}}function L(e){return e===`completed`?`已完成`:`进行中`}function R(e){return{finish:`正常结束`,escalate:`转人工 / 转诊`,manual:`人工结束`}[e]||e||`-`}function z(e,t){E.value=e,N.value=t,I()}function B(e){return{onClick:()=>d.push(`/runs/${e.id}`)}}function V(e){z(e.current||1,e.pageSize||20)}return l(I),(r,l)=>{let u=v(`a-select`),d=v(`a-button`),h=v(`a-tag`),_=v(`a-table`);return o(),f(`div`,O,[l[6]||=i(`div`,{class:`page-heading`},[i(`div`,null,[i(`h1`,null,`执行记录`),i(`p`,null,`按结果筛选实际执行,进入详情复盘每一步回答和分流。`)])],-1),i(`section`,k,[i(`div`,A,[i(`div`,j,[n(c(D)),n(u,{value:P.status,"onUpdate:value":l[0]||=e=>P.status=e,"allow-clear":``,placeholder:`全部状态`,options:[{value:`running`,label:`进行中`},{value:`completed`,label:`已完成`}],onChange:l[1]||=e=>I(!0)},null,8,[`value`]),n(u,{value:P.result,"onUpdate:value":l[2]||=e=>P.result=e,"allow-clear":``,placeholder:`全部结果`,options:[{value:`finish`,label:`正常结束`},{value:`escalate`,label:`转人工 / 转诊`},{value:`manual`,label:`人工结束`}],onChange:l[3]||=e=>I(!0)},null,8,[`value`])]),n(d,{type:`text`,"aria-label":`刷新执行记录`,onClick:l[4]||=e=>I()},{default:m(()=>[n(c(S))]),_:1})]),n(_,{"row-key":`id`,loading:g.value,"data-source":w.value,columns:F,pagination:{current:E.value,pageSize:N.value,total:T.value,showSizeChanger:!0,showTotal:e=>`${e}`},scroll:{x:980},"custom-row":B,onChange:V},{bodyCell:m(({column:n,record:r})=>[n.dataIndex===`id`?(o(),f(`code`,M,`RUN-`+t(String(r.id).padStart(5,`0`)),1)):n.dataIndex===`status`?(o(),p(h,{key:1,color:r.status===`completed`?`green`:`orange`},{default:m(()=>[a(t(L(r.status)),1)]),_:2},1032,[`color`])):n.dataIndex===`result`?(o(),f(s,{key:2},[a(t(R(r.result)),1)],64)):n.dataIndex===`started_at`||n.dataIndex===`completed_at`?(o(),f(s,{key:3},[a(t(r[n.dataIndex]?new Date(r[n.dataIndex]).toLocaleString(`zh-CN`):`-`),1)],64)):n.key===`action`?(o(),p(c(x),{key:4,class:`row-arrow`})):e(``,!0)]),emptyText:m(()=>[...l[5]||=[i(`div`,{class:`empty-copy`},`当前筛选条件下没有执行记录。`,-1)]]),_:1},8,[`loading`,`data-source`,`pagination`])])])}}}),[[`__scopeId`,`data-v-681d0563`]]);export{N as default};

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
import{F as e,a as t}from"./client-Bk7n1O1W.js";var n={icon:{tag:`svg`,attrs:{viewBox:`64 64 896 896`,focusable:`false`},children:[{tag:`path`,attrs:{d:`M512 64C264.6 64 64 264.6 64 512s200.6 448 448 448 448-200.6 448-448S759.4 64 512 64zm0 820c-205.4 0-372-166.6-372-372 0-89 31.3-170.8 83.5-234.8l523.3 523.3C682.8 852.7 601 884 512 884zm288.5-137.2L277.2 223.5C341.2 171.3 423 140 512 140c205.4 0 372 166.6 372 372 0 89-31.3 170.8-83.5 234.8z`}}]},name:`stop`,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=`StopOutlined`,a.inheritAttrs=!1;export{a as t};

File diff suppressed because one or more lines are too long

37
web/dist/assets/modal-C5Vo2Ug5.js vendored Normal file

File diff suppressed because one or more lines are too long

5
web/dist/index.html vendored
View File

@@ -5,17 +5,18 @@
<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-BeO8QXu8.js"></script> <script type="module" crossorigin src="/assets/index-DpE4cxr4.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">
<link rel="modulepreload" crossorigin href="/assets/DeleteOutlined-CRNedxr9.js"> <link rel="modulepreload" crossorigin href="/assets/modal-C5Vo2Ug5.js">
<link rel="modulepreload" crossorigin href="/assets/EyeOutlined-C1VmLQCG.js"> <link rel="modulepreload" crossorigin href="/assets/EyeOutlined-C1VmLQCG.js">
<link rel="modulepreload" crossorigin href="/assets/SearchOutlined-D6c8vQZ_.js"> <link rel="modulepreload" crossorigin href="/assets/SearchOutlined-D6c8vQZ_.js">
<link rel="modulepreload" crossorigin href="/assets/ReloadOutlined-Cih9hKpY.js"> <link rel="modulepreload" crossorigin href="/assets/ReloadOutlined-Cih9hKpY.js">
<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/DeleteOutlined-Bt9-1HZZ.js">
<link rel="modulepreload" crossorigin href="/assets/EditOutlined-BIUW-zYI.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/HistoryOutlined-ABEqcIfz.js">

View File

@@ -4,13 +4,14 @@ export interface Scenario extends BaseEntity { tenant_id: number; name: string;
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; created_by?:number; reviewed_by?:number; creator_name?:string; reviewer_name?: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 KnowledgeSnapshot { card_id:number; card_version_id:number; version:number; title:string; standard_copy:string; forbidden_copy:string; risk_note: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; knowledge?:KnowledgeSnapshot }
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; operator_id:number; current_node_key: string; status: string; answers: Record<string, any>; result: string; started_at: string; completed_at?: string; sop_name?: string; scenario_name?:string; version?:number; operator_name?:string }
export interface KnowledgeCard extends BaseEntity { scenario_id: number; title: string; status: string; version?: number; 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 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; knowledge?:KnowledgeSnapshot }
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 }
export interface RunDetail extends SOPRun { scenario_name:string; version:number; operator_name:string } export interface RunDetail extends SOPRun { scenario_name:string; version:number; operator_name:string }
export interface Role extends BaseEntity { name:string; code:string; permissions:string[] } export interface Role extends BaseEntity { name:string; code:string; permissions:string[] }

View File

@@ -1,22 +1,60 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue' import { computed, nextTick, onMounted, reactive, ref } from 'vue'
import { CheckCircleOutlined, PlayCircleOutlined, SafetyCertificateOutlined } from '@ant-design/icons-vue' import { CheckCircleOutlined, PlayCircleOutlined, SafetyCertificateOutlined, StopOutlined } from '@ant-design/icons-vue'
import { message } from 'ant-design-vue' import { message, Modal } from 'ant-design-vue'
import type { FormInstance } from 'ant-design-vue'
import { useRoute, useRouter } from 'vue-router'
import { api, apiMessage } from '@/api/client' import { api, apiMessage } from '@/api/client'
import type { ScenarioField, SOPNode, SOPRun } from '@/types' import type { ScenarioField, SOPNode, SOPRun } from '@/types'
interface PublishedSOP { id:number; name:string; description:string; scenario_id:number; scenario_name:string; version:number } interface PublishedSOP { id:number; name:string; description:string; scenario_id:number; scenario_name:string; version:number }
interface RunView { run:SOPRun; node:SOPNode; fields:ScenarioField[] } interface RunView { run:SOPRun; node:SOPNode; fields:ScenarioField[] }
const route=useRoute();const router=useRouter()
const loading=ref(true);const sops=ref<PublishedSOP[]>([]);const active=ref<RunView|null>(null);const answers=reactive<Record<string,any>>({});const submitting=ref(false) const loading=ref(true);const sops=ref<PublishedSOP[]>([]);const active=ref<RunView|null>(null);const answers=reactive<Record<string,any>>({});const submitting=ref(false)
const answerForm=ref<FormInstance>()
const feedback=reactive({score:5,comment:''});const feedbackSent=ref(false) const feedback=reactive({score:5,comment:''});const feedbackSent=ref(false)
const currentConfig=computed(()=>active.value?.node.config||{}) 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;const runID=Number(route.query.run||0);if(runID>0)active.value=await api.get<RunView>(`/runs/${runID}`)}catch(error){message.error(apiMessage(error));await router.replace({query:{}})}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]);Object.assign(feedback,{score:5,comment:''});feedbackSent.value=false}catch(error){message.error(apiMessage(error))}} async function clearAnswers(){Object.keys(answers).forEach(k=>delete answers[k]);await nextTick();answerForm.value?.clearValidate()}
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}} async function start(sopID:number){try{active.value=await api.post<RunView>('/runs',{sop_id:sopID});await router.replace({query:{run:String(active.value.run.id)}});await clearAnswers();Object.assign(feedback,{score:5,comment:''});feedbackSent.value=false}catch(error){message.error(apiMessage(error))}}
async function next(){if(!active.value)return;try{await answerForm.value?.validate()}catch{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}});await clearAnswers()}catch(error){message.error(apiMessage(error))}finally{submitting.value=false}}
function finishManually(){
if(!active.value)return
Modal.confirm({
title:'结束本次执行?',
content:'当前节点尚未提交的内容不会保存,执行记录将标记为“人工结束”。',
okText:'确认结束',cancelText:'继续执行',okType:'danger',
async onOk(){
if(!active.value)return
submitting.value=true
try{active.value=await api.post<RunView>(`/runs/${active.value.run.id}/finish`,{result:'manual'});Object.keys(answers).forEach(k=>delete answers[k]);message.success('本次执行已结束')}
catch(error){message.error(apiMessage(error));throw error}
finally{submitting.value=false}
},
})
}
function inputFor(field:ScenarioField){return field.field_type} function inputFor(field:ScenarioField){return field.field_type}
function validationNumber(field:ScenarioField,key:string){const value=field.validation?.[key];return typeof value==='number'?value:undefined}
function fieldRequired(field:ScenarioField){return field.required||(['question','choice'].includes(active.value?.node.type||'')&&currentConfig.value.required===true)}
function fieldRules(field:ScenarioField){
const rules:any[]=[]
if(fieldRequired(field))rules.push({required:true,message:`请填写${field.field_name}`,trigger:['change','blur']})
if(['text','textarea'].includes(field.field_type)){
const min=validationNumber(field,'min_length');const max=validationNumber(field,'max_length')
if(min!==undefined)rules.push({min,message:`${field.field_name}不能少于 ${min} 个字符`,trigger:'blur'})
if(max!==undefined)rules.push({max,message:`${field.field_name}不能超过 ${max} 个字符`,trigger:'blur'})
}
if(field.field_type==='number'){
const min=validationNumber(field,'min');const max=validationNumber(field,'max')
rules.push({type:'number',message:`${field.field_name}必须是数字`,trigger:'change'})
if(min!==undefined)rules.push({type:'number',min,message:`${field.field_name}不能小于 ${min}`,trigger:'change'})
if(max!==undefined)rules.push({type:'number',max,message:`${field.field_name}不能大于 ${max}`,trigger:'change'})
}
return rules
}
async function sendFeedback(){if(!active.value)return;submitting.value=true;try{await api.post(`/runs/${active.value.run.id}/feedback`,feedback);feedbackSent.value=true;message.success('反馈已提交')}catch(error){message.error(apiMessage(error))}finally{submitting.value=false}} async function sendFeedback(){if(!active.value)return;submitting.value=true;try{await api.post(`/runs/${active.value.run.id}/feedback`,feedback);feedbackSent.value=true;message.success('反馈已提交')}catch(error){message.error(apiMessage(error))}finally{submitting.value=false}}
function reset(){active.value=null;load()} async function reset(){active.value=null;await router.replace({query:{}});await load()}
onMounted(load) onMounted(load)
</script> </script>
@@ -31,29 +69,35 @@ onMounted(load)
<div v-else class="run-workspace"> <div v-else class="run-workspace">
<aside class="run-context"> <aside class="run-context">
<span class="run-label">RUN-{{ String(active.run.id).padStart(5,'0') }}</span> <span class="run-label">RUN-{{ String(active.run.id).padStart(5,'0') }}</span>
<h2>执行进行中</h2><p>客户回答会自动保存在当前执行记录中</p> <h2>{{ active.run.status==='completed'?'执行已结束':'执行进行中' }}</h2><p>客户回答会自动保存在当前执行记录中</p>
<div class="context-meta"><span>当前节点</span><b>{{ active.node.title }}</b></div><div class="context-meta"><span>已采集字段</span><b>{{ Object.keys(active.run.answers || {}).length }}</b></div> <div class="context-meta"><span>当前节点</span><b>{{ active.node.title }}</b></div><div class="context-meta"><span>已采集字段</span><b>{{ Object.keys(active.run.answers || {}).length }}</b></div>
<div class="privacy-note"><SafetyCertificateOutlined /><span>敏感信息请遵循企业数据规范</span></div> <div class="privacy-note"><SafetyCertificateOutlined /><span>敏感信息请遵循企业数据规范</span></div>
</aside> </aside>
<main class="conversation surface"> <main class="conversation surface">
<div class="conversation-progress"><span :class="{done:active.run.status==='completed'}"></span><b>{{ active.run.status==='completed'?'流程已完成':'正在执行' }}</b></div> <div class="conversation-progress"><span :class="{done:active.run.status==='completed'}"></span><b>{{ active.run.status==='completed'?'流程已完成':'正在执行' }}</b></div>
<div class="node-content"><small>{{ active.node.type.toUpperCase() }}</small><h1>{{ active.node.title }}</h1><blockquote v-if="active.node.content">{{ active.node.content }}</blockquote></div> <div class="node-content"><small>{{ active.node.type.toUpperCase() }}</small><h1>{{ active.node.title }}</h1><blockquote v-if="active.node.content">{{ active.node.content }}</blockquote></div>
<div v-if="active.run.status==='completed'" class="completed-state"><CheckCircleOutlined /><h3>{{ active.node.type==='escalate'?'已转交处理':'本次执行已完成' }}</h3><p>{{ active.node.content }}</p><div v-if="!feedbackSent" class="quick-feedback"><span>这套 SOP 是否清晰好用?</span><a-rate v-model:value="feedback.score"/><a-textarea v-model:value="feedback.comment" :rows="2" :maxlength="2000" placeholder="可填写需要改进的步骤"/><a-button type="primary" :loading="submitting" @click="sendFeedback">提交反馈</a-button></div><div v-else class="feedback-thanks">反馈已记录,将用于后续优化。</div><a-button @click="reset">执行另一套 SOP</a-button></div> <section v-if="active.node.knowledge" class="knowledge-snapshot">
<div class="knowledge-head"><b>{{ active.node.knowledge.title }}</b><span>知识卡 V{{ active.node.knowledge.version }}</span></div>
<div class="standard-copy"><small>标准话术</small><p>{{ active.node.knowledge.standard_copy }}</p></div>
<div v-if="active.node.knowledge.forbidden_copy" class="knowledge-note forbidden"><small>禁用表达</small><p>{{ active.node.knowledge.forbidden_copy }}</p></div>
<div v-if="active.node.knowledge.risk_note" class="knowledge-note risk"><small>风险提醒</small><p>{{ active.node.knowledge.risk_note }}</p></div>
</section>
<div v-if="active.run.status==='completed'" class="completed-state"><CheckCircleOutlined /><h3>{{ active.run.result==='manual'?'已人工结束':active.node.type==='escalate'?'已转交处理':'本次执行已完成' }}</h3><p>{{ active.run.result==='manual'?'本次执行由操作人员在当前节点结束。':active.node.content }}</p><div v-if="!feedbackSent" class="quick-feedback"><span>这套 SOP 是否清晰好用?</span><a-rate v-model:value="feedback.score"/><a-textarea v-model:value="feedback.comment" :rows="2" :maxlength="2000" placeholder="可填写需要改进的步骤"/><a-button type="primary" :loading="submitting" @click="sendFeedback">提交反馈</a-button></div><div v-else class="feedback-thanks">反馈已记录,将用于后续优化。</div><a-button @click="reset">执行另一套 SOP</a-button></div>
<template v-else> <template v-else>
<a-form layout="vertical" class="answer-form"> <a-form ref="answerForm" :model="answers" layout="vertical" class="answer-form">
<a-form-item v-for="field in currentFields" :key="field.id" :label="field.field_name" :required="field.required"> <a-form-item v-for="field in currentFields" :key="field.id" :name="field.field_key" :label="field.field_name" :required="fieldRequired(field)" :rules="fieldRules(field)">
<a-input v-if="inputFor(field)==='text'" v-model:value="answers[field.field_key]" /> <a-radio-group v-if="active.node.type==='choice'" v-model:value="answers[field.field_key]" class="choice-group"><a-radio-button v-for="option in currentConfig.options||[]" :key="option" :value="option">{{ option }}</a-radio-button></a-radio-group>
<a-textarea v-else-if="inputFor(field)==='textarea'" v-model:value="answers[field.field_key]" :rows="3" /> <a-input v-else-if="inputFor(field)==='text'" v-model:value="answers[field.field_key]" :maxlength="validationNumber(field,'max_length')" show-count />
<a-input-number v-else-if="inputFor(field)==='number'" v-model:value="answers[field.field_key]" style="width:100%" /> <a-textarea v-else-if="inputFor(field)==='textarea'" v-model:value="answers[field.field_key]" :rows="3" :maxlength="validationNumber(field,'max_length')" show-count />
<a-input-number v-else-if="inputFor(field)==='number'" v-model:value="answers[field.field_key]" :min="validationNumber(field,'min')" :max="validationNumber(field,'max')" style="width:100%" />
<a-radio-group v-else-if="inputFor(field)==='boolean'" v-model:value="answers[field.field_key]"><a-radio :value="true">是</a-radio><a-radio :value="false"></a-radio></a-radio-group> <a-radio-group v-else-if="inputFor(field)==='boolean'" v-model:value="answers[field.field_key]"><a-radio :value="true">是</a-radio><a-radio :value="false"></a-radio></a-radio-group>
<a-select v-else-if="inputFor(field)==='select'" v-model:value="answers[field.field_key]" :options="(field.options||[]).map(value=>({value,label:value}))" /> <a-select v-else-if="inputFor(field)==='select'" v-model:value="answers[field.field_key]" :options="(field.options||[]).map(value=>({value,label:value}))" />
<a-select v-else-if="inputFor(field)==='multiselect'" v-model:value="answers[field.field_key]" mode="multiple" :options="(field.options||[]).map(value=>({value,label:value}))" /> <a-select v-else-if="inputFor(field)==='multiselect'" v-model:value="answers[field.field_key]" mode="multiple" :options="(field.options||[]).map(value=>({value,label:value}))" />
<a-date-picker v-else-if="inputFor(field)==='date'" v-model:value="answers[field.field_key]" style="width:100%" /> <a-date-picker v-else-if="inputFor(field)==='date'" v-model:value="answers[field.field_key]" value-format="YYYY-MM-DD" style="width:100%" />
<a-input v-else v-model:value="answers[field.field_key]" /> <a-input v-else v-model:value="answers[field.field_key]" />
</a-form-item> </a-form-item>
<a-form-item v-if="active.node.type==='choice'&&currentFields.length===0"><a-radio-group v-model:value="answers[currentConfig.field_key]" class="choice-group"><a-radio-button v-for="option in currentConfig.options||[]" :key="option" :value="option">{{ option }}</a-radio-button></a-radio-group></a-form-item>
</a-form> </a-form>
<div class="run-actions"><span>{{ currentFields.length ? '填写后继续下一步' : '确认当前话术已完成' }}</span><a-button type="primary" size="large" :loading="submitting" @click="next">继续下一步</a-button></div> <div class="run-actions"><span>{{ currentFields.length ? '填写后继续下一步' : '确认当前话术已完成' }}</span><div><a-button danger :disabled="submitting" @click="finishManually"><StopOutlined />人工结束</a-button><a-button type="primary" size="large" :loading="submitting" @click="next">继续下一步</a-button></div></div>
</template> </template>
</main> </main>
</div> </div>
@@ -62,6 +106,7 @@ onMounted(load)
</template> </template>
<style scoped> <style scoped>
.execution-shell{max-width:1220px}.sop-catalog{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:14px}.sop-entry{min-height:150px;display:grid;grid-template-columns:40px 1fr 42px;align-items:start;gap:14px;padding:22px;text-align:left;cursor:pointer}.sop-entry:hover{border-color:#9ac8b8;box-shadow:0 8px 24px rgba(32,40,37,.07)}.entry-seq{color:#8c9893;font-family:monospace;font-size:12px}.sop-entry small{color:var(--green);font-weight:650}.sop-entry h2{margin:8px 0 7px;font-family:"Noto Serif SC",serif;font-size:20px}.sop-entry p{margin:0;color:var(--muted);line-height:1.6}.play{width:38px;height:38px;display:grid;place-items:center;color:white;background:#202825;border-radius:4px;font-size:18px}.run-workspace{display:grid;grid-template-columns:270px minmax(0,1fr);gap:18px}.run-context{align-self:start;padding:24px;color:white;background:#202825;border-radius:6px;position:sticky;top:86px}.run-label{color:#72c9ab;font-size:11px;font-weight:700}.run-context h2{margin:12px 0 8px;font-family:"Noto Serif SC",serif}.run-context>p{margin:0 0 28px;color:#aebdb7;line-height:1.6}.context-meta{display:flex;align-items:center;justify-content:space-between;padding:13px 0;border-top:1px solid #3a4742}.context-meta span{color:#9eada7;font-size:12px}.privacy-note{display:flex;gap:8px;margin-top:28px;color:#889b93;font-size:11px}.conversation{min-height:590px;padding:30px 34px}.conversation-progress{display:flex;align-items:center;gap:8px;color:#66736d;font-size:12px}.conversation-progress span{width:8px;height:8px;border-radius:50%;background:#d08736;box-shadow:0 0 0 4px #faefe1}.conversation-progress span.done{background:var(--green);box-shadow:0 0 0 4px #e4f1ec}.node-content{padding:44px 0 26px}.node-content small{color:var(--green);font-size:10px;font-weight:800}.node-content h1{margin:8px 0 22px;font-family:"Noto Serif SC",serif;font-size:28px}.node-content blockquote{margin:0;padding:18px 20px;color:#29342f;background:#f2f6f4;border-left:3px solid var(--green);font-size:17px;line-height:1.8}.answer-form{max-width:680px}.choice-group{display:flex;flex-wrap:wrap}.run-actions{display:flex;align-items:center;justify-content:space-between;gap:16px;margin:24px -34px -30px;padding:18px 34px;border-top:1px solid var(--line);color:var(--muted);font-size:12px}.completed-state{padding:40px 0;text-align:center}.completed-state>span{color:var(--green);font-size:50px}.completed-state h3{margin:14px 0 8px;font-family:"Noto Serif SC",serif;font-size:24px}.completed-state p{margin:0 0 24px;color:var(--muted)}.quick-feedback{max-width:620px;display:grid;grid-template-columns:180px 1fr auto;align-items:center;gap:12px;margin:0 auto 18px;padding:16px;text-align:left;background:#f5f8f6;border-top:1px solid var(--line);border-bottom:1px solid var(--line)}.quick-feedback>span{font-size:12px;font-weight:650}.quick-feedback :deep(.ant-rate){font-size:18px}.quick-feedback :deep(.ant-input){grid-column:1/3}.feedback-thanks{max-width:520px;margin:0 auto 18px;padding:12px;color:var(--green);background:#edf6f2;font-size:12px} .execution-shell{max-width:1220px}.sop-catalog{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:14px}.sop-entry{min-height:150px;display:grid;grid-template-columns:40px 1fr 42px;align-items:start;gap:14px;padding:22px;text-align:left;cursor:pointer}.sop-entry:hover{border-color:#9ac8b8;box-shadow:0 8px 24px rgba(32,40,37,.07)}.entry-seq{color:#8c9893;font-family:monospace;font-size:12px}.sop-entry small{color:var(--green);font-weight:650}.sop-entry h2{margin:8px 0 7px;font-family:"Noto Serif SC",serif;font-size:20px}.sop-entry p{margin:0;color:var(--muted);line-height:1.6}.play{width:38px;height:38px;display:grid;place-items:center;color:white;background:#202825;border-radius:4px;font-size:18px}.run-workspace{display:grid;grid-template-columns:270px minmax(0,1fr);gap:18px}.run-context{align-self:start;padding:24px;color:white;background:#202825;border-radius:6px;position:sticky;top:86px}.run-label{color:#72c9ab;font-size:11px;font-weight:700}.run-context h2{margin:12px 0 8px;font-family:"Noto Serif SC",serif}.run-context>p{margin:0 0 28px;color:#aebdb7;line-height:1.6}.context-meta{display:flex;align-items:center;justify-content:space-between;padding:13px 0;border-top:1px solid #3a4742}.context-meta span{color:#9eada7;font-size:12px}.privacy-note{display:flex;gap:8px;margin-top:28px;color:#889b93;font-size:11px}.conversation{min-height:590px;padding:30px 34px}.conversation-progress{display:flex;align-items:center;gap:8px;color:#66736d;font-size:12px}.conversation-progress span{width:8px;height:8px;border-radius:50%;background:#d08736;box-shadow:0 0 0 4px #faefe1}.conversation-progress span.done{background:var(--green);box-shadow:0 0 0 4px #e4f1ec}.node-content{padding:44px 0 26px}.node-content small{color:var(--green);font-size:10px;font-weight:800}.node-content h1{margin:8px 0 22px;font-family:"Noto Serif SC",serif;font-size:28px}.node-content blockquote{margin:0;padding:18px 20px;color:#29342f;background:#f2f6f4;border-left:3px solid var(--green);font-size:17px;line-height:1.8}.answer-form{max-width:680px}.choice-group{display:flex;flex-wrap:wrap}.run-actions{display:flex;align-items:center;justify-content:space-between;gap:16px;margin:24px -34px -30px;padding:18px 34px;border-top:1px solid var(--line);color:var(--muted);font-size:12px}.run-actions>div{display:flex;align-items:center;gap:9px}.completed-state{padding:40px 0;text-align:center}.completed-state>span{color:var(--green);font-size:50px}.completed-state h3{margin:14px 0 8px;font-family:"Noto Serif SC",serif;font-size:24px}.completed-state p{margin:0 0 24px;color:var(--muted)}.quick-feedback{max-width:620px;display:grid;grid-template-columns:180px 1fr auto;align-items:center;gap:12px;margin:0 auto 18px;padding:16px;text-align:left;background:#f5f8f6;border-top:1px solid var(--line);border-bottom:1px solid var(--line)}.quick-feedback>span{font-size:12px;font-weight:650}.quick-feedback :deep(.ant-rate){font-size:18px}.quick-feedback :deep(.ant-input){grid-column:1/3}.feedback-thanks{max-width:520px;margin:0 auto 18px;padding:12px;color:var(--green);background:#edf6f2;font-size:12px}
.knowledge-snapshot{margin:0 0 24px;border-top:1px solid var(--line);border-bottom:1px solid var(--line)}.knowledge-head{display:flex;align-items:center;justify-content:space-between;padding:12px 0}.knowledge-head span{color:var(--muted);font-size:11px}.standard-copy,.knowledge-note{padding:14px 16px;border-left:3px solid var(--green);background:#f2f7f5}.standard-copy small,.knowledge-note small{font-size:10px;font-weight:750}.standard-copy p,.knowledge-note p{margin:6px 0 0;line-height:1.7}.knowledge-note{margin-top:8px;border-left-color:#c4573f;background:#fbf3f0}.knowledge-note.risk{border-left-color:#bd7b24;background:#fbf6eb}
@media(max-width:800px){.sop-catalog{grid-template-columns:1fr}.run-workspace{grid-template-columns:1fr}.run-context{position:static}.conversation{padding:22px}.run-actions{margin:24px -22px -22px;padding:16px 22px}.run-actions span{display:none}.quick-feedback{grid-template-columns:1fr}.quick-feedback :deep(.ant-input){grid-column:auto}} @media(max-width:800px){.sop-catalog{grid-template-columns:1fr}.run-workspace{grid-template-columns:1fr}.run-context{position:static}.conversation{padding:22px}.run-actions{margin:24px -22px -22px;padding:16px 22px}.run-actions span{display:none}.quick-feedback{grid-template-columns:1fr}.quick-feedback :deep(.ant-input){grid-column:auto}}
</style> </style>

View File

@@ -13,7 +13,7 @@ const fieldMap=computed(()=>new Map((detail.value?.fields||[]).map(field=>[field
const answers=computed(()=>Object.entries(detail.value?.run.answers||{})) const answers=computed(()=>Object.entries(detail.value?.run.answers||{}))
const canFeedback=computed(()=>auth.can('runs.feedback')&&detail.value?.run.status==='completed'&&!detail.value.feedback.some(item=>item.user_id===auth.user?.user_id)) const canFeedback=computed(()=>auth.can('runs.feedback')&&detail.value?.run.status==='completed'&&!detail.value.feedback.some(item=>item.user_id===auth.user?.user_id))
function resultText(v:string){return{finish:'正常结束',escalate:'转人工 / 转诊',manual:'人工结束'}[v]||v||'进行中'} function resultText(v:string){return{finish:'正常结束',escalate:'转人工 / 转诊',manual:'人工结束'}[v]||v||'进行中'}
function actionText(v:string){return{start:'开始执行',enter:'进入节点',answer:'提交回答'}[v]||v} function actionText(v:string){return{start:'开始执行',enter:'进入节点',answer:'提交回答',finish:'人工结束'}[v]||v}
function valueText(value:any){if(value===true)return'是';if(value===false)return'否';if(Array.isArray(value))return value.join('、');if(value===null||value===undefined||value==='')return'-';return String(value)} function valueText(value:any){if(value===true)return'是';if(value===false)return'否';if(Array.isArray(value))return value.join('、');if(value===null||value===undefined||value==='')return'-';return String(value)}
function answerEntries(event:RunEvent){const values=event.payload?.answers||{};return Object.entries(values)} function answerEntries(event:RunEvent){const values=event.payload?.answers||{};return Object.entries(values)}
async function load(){loading.value=true;try{detail.value=await api.get<DetailResponse>(`/runs/${route.params.id}/detail`)}catch(error){message.error(apiMessage(error));router.replace('/runs')}finally{loading.value=false}} async function load(){loading.value=true;try{detail.value=await api.get<DetailResponse>(`/runs/${route.params.id}/detail`)}catch(error){message.error(apiMessage(error));router.replace('/runs')}finally{loading.value=false}}
@@ -35,9 +35,10 @@ onMounted(load)
<main class="surface timeline-panel"> <main class="surface timeline-panel">
<div class="section-title"><span>执行轨迹</span><small>按实际发生顺序记录</small></div> <div class="section-title"><span>执行轨迹</span><small>按实际发生顺序记录</small></div>
<a-timeline class="run-timeline"> <a-timeline class="run-timeline">
<a-timeline-item v-for="event in detail.events" :key="event.id" :color="event.action==='answer'?'blue':event.node_type==='escalate'?'red':'green'"> <a-timeline-item v-for="event in detail.events" :key="event.id" :color="event.action==='finish'||event.node_type==='escalate'?'red':event.action==='answer'?'blue':'green'">
<div class="event-head"><b>{{ event.node_title||event.node_key }}</b><a-tag>{{ actionText(event.action) }}</a-tag><time>{{ new Date(event.created_at).toLocaleString('zh-CN') }}</time></div> <div class="event-head"><b>{{ event.node_title||event.node_key }}</b><a-tag>{{ actionText(event.action) }}</a-tag><time>{{ new Date(event.created_at).toLocaleString('zh-CN') }}</time></div>
<p v-if="event.action==='enter'&&event.node_content">{{ event.node_content }}</p> <p v-if="event.action==='enter'&&event.node_content">{{ event.node_content }}</p>
<div v-if="event.action==='enter'&&event.knowledge" class="event-knowledge"><div><b>{{ event.knowledge.title }}</b><small>V{{ event.knowledge.version }}</small></div><p>{{ event.knowledge.standard_copy }}</p><p v-if="event.knowledge.risk_note" class="risk-note">{{ event.knowledge.risk_note }}</p></div>
<div v-if="answerEntries(event).length" class="event-answers"><span v-for="[key,value] in answerEntries(event)" :key="key"><small>{{ fieldMap.get(key)||key }}</small><b>{{ valueText(value) }}</b></span></div> <div v-if="answerEntries(event).length" class="event-answers"><span v-for="[key,value] in answerEntries(event)" :key="key"><small>{{ fieldMap.get(key)||key }}</small><b>{{ valueText(value) }}</b></span></div>
</a-timeline-item> </a-timeline-item>
</a-timeline> </a-timeline>
@@ -62,4 +63,5 @@ onMounted(load)
<style scoped> <style scoped>
.run-detail-page{max-width:1320px}.back-link{height:auto;margin:0 0 14px;padding:0}.detail-heading{display:flex;align-items:flex-end;justify-content:space-between;gap:24px;margin-bottom:20px}.run-code{color:var(--green);font-family:monospace;font-size:12px;font-weight:750}.detail-heading h1{margin:7px 0 5px;font-family:"Noto Serif SC",serif;font-size:28px}.detail-heading p{margin:0;color:var(--muted)}.result-block{min-width:210px;display:flex;align-items:center;gap:12px;padding:15px 17px;border-left:3px solid}.result-block>span:first-child{font-size:25px}.result-block>span:last-child{display:flex;flex-direction:column}.result-block small{font-size:10px}.result-block b{margin-top:3px}.result-block.success{color:#17664f;background:#eaf4f0;border-color:#25866a}.result-block.warning{color:#8d5b16;background:#fbf2e3;border-color:#c08026}.detail-layout{display:grid;grid-template-columns:minmax(0,1fr) 330px;gap:16px}.timeline-panel{min-height:560px;padding:22px 24px}.section-title{display:flex;align-items:baseline;justify-content:space-between;gap:12px;margin-bottom:22px}.section-title span{font-weight:700}.section-title small{color:var(--muted);font-size:11px}.run-timeline{padding-top:5px}.event-head{display:flex;align-items:center;gap:9px;min-height:27px}.event-head time{margin-left:auto;color:#8b9691;font-size:11px}.run-timeline p{margin:8px 0 0;color:#56635d;line-height:1.65}.event-answers{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:7px;margin-top:10px}.event-answers span{display:flex;justify-content:space-between;gap:10px;padding:8px 10px;background:#f4f7f5;border-left:2px solid #a6cbbd}.event-answers small{color:var(--muted)}.event-answers b{font-size:12px}.detail-aside{display:flex;flex-direction:column;gap:16px}.summary-panel,.answer-panel{padding:20px}.summary-panel dl{margin:0}.summary-panel dl>div{display:flex;justify-content:space-between;gap:15px;padding:10px 0;border-bottom:1px solid #edf0ee}.summary-panel dl>div:last-child{border-bottom:0}.summary-panel dt{color:var(--muted);font-size:12px}.summary-panel dd{margin:0;text-align:right;font-size:12px}.answer-list>div{display:flex;flex-direction:column;padding:10px 0;border-bottom:1px solid #edf0ee}.answer-list>div:last-child{border-bottom:0}.answer-list span{color:var(--muted);font-size:11px}.answer-list b{margin-top:4px;font-size:13px}.aside-empty{padding:24px 0;color:#929d98;text-align:center;font-size:12px}.feedback-band{margin-top:16px;padding:22px 24px}.feedback-list{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:16px}.feedback-list article{padding:14px 0;border-top:1px solid var(--line)}.feedback-list article>div{display:flex;align-items:center;justify-content:space-between}.feedback-list :deep(.ant-rate){font-size:15px}.feedback-list p{margin:9px 0;color:#54615b}.feedback-list time{color:#929d98;font-size:11px}.feedback-form{display:grid;grid-template-columns:220px minmax(0,1fr) auto;align-items:center;gap:14px;padding-top:16px;border-top:1px solid var(--line)}.feedback-form>div{display:flex;flex-direction:column;gap:6px}.feedback-form>div>span{font-size:12px;font-weight:650}.feedback-form :deep(.ant-rate){font-size:20px}@media(max-width:900px){.detail-layout{grid-template-columns:1fr}.detail-aside{display:grid;grid-template-columns:1fr 1fr}.feedback-form{grid-template-columns:1fr}.feedback-list{grid-template-columns:1fr}}@media(max-width:620px){.detail-heading{align-items:flex-start;flex-direction:column}.result-block{width:100%}.detail-aside{grid-template-columns:1fr}.timeline-panel{padding:18px}.event-head{align-items:flex-start;flex-wrap:wrap}.event-head time{width:100%;margin:0}.event-answers{grid-template-columns:1fr}} .run-detail-page{max-width:1320px}.back-link{height:auto;margin:0 0 14px;padding:0}.detail-heading{display:flex;align-items:flex-end;justify-content:space-between;gap:24px;margin-bottom:20px}.run-code{color:var(--green);font-family:monospace;font-size:12px;font-weight:750}.detail-heading h1{margin:7px 0 5px;font-family:"Noto Serif SC",serif;font-size:28px}.detail-heading p{margin:0;color:var(--muted)}.result-block{min-width:210px;display:flex;align-items:center;gap:12px;padding:15px 17px;border-left:3px solid}.result-block>span:first-child{font-size:25px}.result-block>span:last-child{display:flex;flex-direction:column}.result-block small{font-size:10px}.result-block b{margin-top:3px}.result-block.success{color:#17664f;background:#eaf4f0;border-color:#25866a}.result-block.warning{color:#8d5b16;background:#fbf2e3;border-color:#c08026}.detail-layout{display:grid;grid-template-columns:minmax(0,1fr) 330px;gap:16px}.timeline-panel{min-height:560px;padding:22px 24px}.section-title{display:flex;align-items:baseline;justify-content:space-between;gap:12px;margin-bottom:22px}.section-title span{font-weight:700}.section-title small{color:var(--muted);font-size:11px}.run-timeline{padding-top:5px}.event-head{display:flex;align-items:center;gap:9px;min-height:27px}.event-head time{margin-left:auto;color:#8b9691;font-size:11px}.run-timeline p{margin:8px 0 0;color:#56635d;line-height:1.65}.event-answers{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:7px;margin-top:10px}.event-answers span{display:flex;justify-content:space-between;gap:10px;padding:8px 10px;background:#f4f7f5;border-left:2px solid #a6cbbd}.event-answers small{color:var(--muted)}.event-answers b{font-size:12px}.detail-aside{display:flex;flex-direction:column;gap:16px}.summary-panel,.answer-panel{padding:20px}.summary-panel dl{margin:0}.summary-panel dl>div{display:flex;justify-content:space-between;gap:15px;padding:10px 0;border-bottom:1px solid #edf0ee}.summary-panel dl>div:last-child{border-bottom:0}.summary-panel dt{color:var(--muted);font-size:12px}.summary-panel dd{margin:0;text-align:right;font-size:12px}.answer-list>div{display:flex;flex-direction:column;padding:10px 0;border-bottom:1px solid #edf0ee}.answer-list>div:last-child{border-bottom:0}.answer-list span{color:var(--muted);font-size:11px}.answer-list b{margin-top:4px;font-size:13px}.aside-empty{padding:24px 0;color:#929d98;text-align:center;font-size:12px}.feedback-band{margin-top:16px;padding:22px 24px}.feedback-list{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:16px}.feedback-list article{padding:14px 0;border-top:1px solid var(--line)}.feedback-list article>div{display:flex;align-items:center;justify-content:space-between}.feedback-list :deep(.ant-rate){font-size:15px}.feedback-list p{margin:9px 0;color:#54615b}.feedback-list time{color:#929d98;font-size:11px}.feedback-form{display:grid;grid-template-columns:220px minmax(0,1fr) auto;align-items:center;gap:14px;padding-top:16px;border-top:1px solid var(--line)}.feedback-form>div{display:flex;flex-direction:column;gap:6px}.feedback-form>div>span{font-size:12px;font-weight:650}.feedback-form :deep(.ant-rate){font-size:20px}@media(max-width:900px){.detail-layout{grid-template-columns:1fr}.detail-aside{display:grid;grid-template-columns:1fr 1fr}.feedback-form{grid-template-columns:1fr}.feedback-list{grid-template-columns:1fr}}@media(max-width:620px){.detail-heading{align-items:flex-start;flex-direction:column}.result-block{width:100%}.detail-aside{grid-template-columns:1fr}.timeline-panel{padding:18px}.event-head{align-items:flex-start;flex-wrap:wrap}.event-head time{width:100%;margin:0}.event-answers{grid-template-columns:1fr}}
.event-knowledge{margin-top:10px;padding:12px 14px;background:#f2f7f5;border-left:3px solid var(--green)}.event-knowledge>div{display:flex;justify-content:space-between;gap:12px}.event-knowledge small{color:var(--muted)}.event-knowledge .risk-note{padding-top:8px;border-top:1px solid #dce9e4;color:#8d5b16}
</style> </style>

View File

@@ -1,22 +1,32 @@
<script setup lang="ts"> <script setup lang="ts">
import { onMounted, reactive, ref } from 'vue' import { onMounted, reactive, ref } from 'vue'
import { FilterOutlined, ReloadOutlined, RightOutlined } from '@ant-design/icons-vue' import { FilterOutlined, PlayCircleOutlined, ReloadOutlined, RightOutlined } from '@ant-design/icons-vue'
import { message } from 'ant-design-vue' import { message } from 'ant-design-vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { api, apiMessage } from '@/api/client' import { api, apiMessage } from '@/api/client'
import { useAuthStore } from '@/stores/auth'
import type { SOPRun } from '@/types' import type { SOPRun } from '@/types'
const router=useRouter() const router=useRouter()
const auth=useAuthStore()
const loading=ref(false);const items=ref<SOPRun[]>([]);const total=ref(0);const page=ref(1);const pageSize=ref(20) const loading=ref(false);const items=ref<SOPRun[]>([]);const total=ref(0);const page=ref(1);const pageSize=ref(20)
const filters=reactive({status:'',result:''}) interface FilterOption{value:number;label:string}
const columns=[{title:'执行编号',dataIndex:'id',width:130},{title:'SOP',dataIndex:'sop_name'},{title:'状态',dataIndex:'status',width:110},{title:'结果',dataIndex:'result',width:130},{title:'开始时间',dataIndex:'started_at',width:190},{title:'完成时间',dataIndex:'completed_at',width:190},{title:'',key:'action',width:54}] const filters=reactive({status:'',result:'',sop_id:undefined as number|undefined,operator_id:undefined as number|undefined})
async function load(reset=false){if(reset)page.value=1;loading.value=true;try{const data=await api.get<{items:SOPRun[];total:number}>('/runs',{params:{page:page.value,page_size:pageSize.value,status:filters.status||undefined,result:filters.result||undefined}});items.value=data.items||[];total.value=data.total}catch(error){message.error(apiMessage(error))}finally{loading.value=false}} const dateRange=ref<any[]>([])
const filterOptions=reactive<{sops:FilterOption[];operators:FilterOption[]}>({sops:[],operators:[]})
const columns=[{title:'执行编号',dataIndex:'id',width:130},{title:'SOP / 场景',key:'sop',width:260},{title:'执行人',dataIndex:'operator_name',width:130},{title:'状态',dataIndex:'status',width:100},{title:'结果',dataIndex:'result',width:130},{title:'开始时间',dataIndex:'started_at',width:180},{title:'完成时间',dataIndex:'completed_at',width:180},{title:'',key:'action',width:54}]
function queryParams(){return{page:page.value,page_size:pageSize.value,status:filters.status||undefined,result:filters.result||undefined,sop_id:filters.sop_id,operator_id:filters.operator_id,started_from:dateRange.value?.[0]?.startOf('day').toISOString(),started_to:dateRange.value?.[1]?.endOf('day').toISOString()}}
async function load(reset=false){if(reset)page.value=1;loading.value=true;try{const data=await api.get<{items:SOPRun[];total:number}>('/runs',{params:queryParams()});items.value=data.items||[];total.value=data.total}catch(error){message.error(apiMessage(error))}finally{loading.value=false}}
async function loadOptions(){try{const data=await api.get<{sops:FilterOption[];operators:FilterOption[]}>('/runs/options');filterOptions.sops=data.sops||[];filterOptions.operators=data.operators||[]}catch(error){message.error(apiMessage(error))}}
function resetFilters(){Object.assign(filters,{status:'',result:'',sop_id:undefined,operator_id:undefined});dateRange.value=[];load(true)}
function statusText(v:string){return v==='completed'?'已完成':'进行中'} function statusText(v:string){return v==='completed'?'已完成':'进行中'}
function resultText(v:string){return{finish:'正常结束',escalate:'转人工 / 转诊',manual:'人工结束'}[v]||v||'-'} function resultText(v:string){return{finish:'正常结束',escalate:'转人工 / 转诊',manual:'人工结束'}[v]||v||'-'}
function changePage(next:number,size:number){page.value=next;pageSize.value=size;load()} function changePage(next:number,size:number){page.value=next;pageSize.value=size;load()}
function rowProps(record:SOPRun){return{onClick:()=>router.push(`/runs/${record.id}`)}} function rowProps(record:SOPRun){return{onClick:()=>router.push(`/runs/${record.id}`)}}
function canContinue(record:SOPRun){return record.status==='running'&&auth.can('sop.execute')&&(record.operator_id===auth.user?.user_id||auth.can('*'))}
function continueRun(record:SOPRun){router.push({path:'/execute',query:{run:String(record.id)}})}
function tableChange(pager:{current?:number;pageSize?:number}){changePage(pager.current||1,pager.pageSize||20)} function tableChange(pager:{current?:number;pageSize?:number}){changePage(pager.current||1,pager.pageSize||20)}
onMounted(load) onMounted(()=>{load();loadOptions()})
</script> </script>
<template> <template>
@@ -24,11 +34,11 @@ onMounted(load)
<div class="page-heading"><div><h1>执行记录</h1><p>按结果筛选实际执行进入详情复盘每一步回答和分流</p></div></div> <div class="page-heading"><div><h1>执行记录</h1><p>按结果筛选实际执行进入详情复盘每一步回答和分流</p></div></div>
<section class="surface"> <section class="surface">
<div class="history-toolbar"> <div class="history-toolbar">
<div class="filter-set"><FilterOutlined /><a-select v-model:value="filters.status" allow-clear placeholder="全部状态" :options="[{value:'running',label:'进行中'},{value:'completed',label:'已完成'}]" @change="load(true)"/><a-select v-model:value="filters.result" allow-clear placeholder="全部结果" :options="[{value:'finish',label:'正常结束'},{value:'escalate',label:'转人工 / 转诊'},{value:'manual',label:'人工结束'}]" @change="load(true)"/></div> <div class="filter-set"><FilterOutlined /><a-select v-model:value="filters.status" allow-clear placeholder="全部状态" :options="[{value:'running',label:'进行中'},{value:'completed',label:'已完成'}]" @change="load(true)"/><a-select v-model:value="filters.result" allow-clear placeholder="全部结果" :options="[{value:'finish',label:'正常结束'},{value:'escalate',label:'转人工 / 转诊'},{value:'manual',label:'人工结束'}]" @change="load(true)"/><a-select v-model:value="filters.sop_id" allow-clear show-search option-filter-prop="label" placeholder="全部 SOP" :options="filterOptions.sops" @change="load(true)"/><a-select v-model:value="filters.operator_id" allow-clear show-search option-filter-prop="label" placeholder="全部执行人" :options="filterOptions.operators" @change="load(true)"/><a-range-picker v-model:value="dateRange" :allow-clear="true" @change="load(true)"/><a-button type="link" @click="resetFilters">重置</a-button></div>
<a-button type="text" aria-label="刷新执行记录" @click="load()"><ReloadOutlined /></a-button> <a-button type="text" aria-label="刷新执行记录" @click="load()"><ReloadOutlined /></a-button>
</div> </div>
<a-table row-key="id" :loading="loading" :data-source="items" :columns="columns" :pagination="{current:page,pageSize,total,showSizeChanger:true,showTotal:(value:number)=>`共 ${value} 条`}" :scroll="{x:980}" :custom-row="rowProps" @change="tableChange"> <a-table row-key="id" :loading="loading" :data-source="items" :columns="columns" :pagination="{current:page,pageSize,total,showSizeChanger:true,showTotal:(value:number)=>`共 ${value} 条`}" :scroll="{x:980}" :custom-row="rowProps" @change="tableChange">
<template #bodyCell="{column,record}"><template v-if="column.dataIndex==='id'"><code>RUN-{{ String(record.id).padStart(5,'0') }}</code></template><template v-else-if="column.dataIndex==='status'"><a-tag :color="record.status==='completed'?'green':'orange'">{{ statusText(record.status) }}</a-tag></template><template v-else-if="column.dataIndex==='result'">{{ resultText(record.result) }}</template><template v-else-if="column.dataIndex==='started_at'||column.dataIndex==='completed_at'">{{ record[column.dataIndex]?new Date(record[column.dataIndex]).toLocaleString('zh-CN'):'-' }}</template><template v-else-if="column.key==='action'"><RightOutlined class="row-arrow" /></template></template> <template #bodyCell="{column,record}"><template v-if="column.dataIndex==='id'"><code>RUN-{{ String(record.id).padStart(5,'0') }}</code></template><template v-else-if="column.key==='sop'"><div class="run-sop"><b>{{ record.sop_name }}</b><small>{{ record.scenario_name }} · V{{ record.version }}</small></div></template><template v-else-if="column.dataIndex==='status'"><a-tag :color="record.status==='completed'?'green':'orange'">{{ statusText(record.status) }}</a-tag></template><template v-else-if="column.dataIndex==='result'">{{ resultText(record.result) }}</template><template v-else-if="column.dataIndex==='started_at'||column.dataIndex==='completed_at'">{{ record[column.dataIndex]?new Date(record[column.dataIndex]).toLocaleString('zh-CN'):'-' }}</template><template v-else-if="column.key==='action'"><a-tooltip v-if="canContinue(record)" title="继续执行"><a-button type="text" aria-label="继续执行" @click.stop="continueRun(record)"><PlayCircleOutlined /></a-button></a-tooltip><RightOutlined v-else class="row-arrow" /></template></template>
<template #emptyText><div class="empty-copy">当前筛选条件下没有执行记录</div></template> <template #emptyText><div class="empty-copy">当前筛选条件下没有执行记录</div></template>
</a-table> </a-table>
</section> </section>
@@ -36,5 +46,5 @@ onMounted(load)
</template> </template>
<style scoped> <style scoped>
.history-toolbar{min-height:58px;display:flex;align-items:center;justify-content:space-between;gap:12px;padding:10px 14px;border-bottom:1px solid var(--line)}.filter-set{display:flex;align-items:center;gap:9px;color:#7a8781}.filter-set :deep(.ant-select){width:160px}.surface :deep(.ant-table-row){cursor:pointer}.surface :deep(.ant-table-row:hover) .row-arrow{color:var(--green);transform:translateX(2px)}code{padding:2px 5px;color:#17654f;background:#eef5f2;border-radius:3px}.row-arrow{color:#9ba6a1;transition:all .16s ease}@media(max-width:600px){.history-toolbar{align-items:flex-start}.filter-set{flex-wrap:wrap}.filter-set :deep(.ant-select){width:138px}} .history-toolbar{min-height:58px;display:flex;align-items:center;justify-content:space-between;gap:12px;padding:10px 14px;border-bottom:1px solid var(--line)}.filter-set{display:flex;align-items:center;flex-wrap:wrap;gap:9px;color:#7a8781}.filter-set :deep(.ant-select){width:150px}.filter-set :deep(.ant-picker){width:240px}.run-sop{display:flex;flex-direction:column}.run-sop small{margin-top:4px;color:var(--muted)}.surface :deep(.ant-table-row){cursor:pointer}.surface :deep(.ant-table-row:hover) .row-arrow{color:var(--green);transform:translateX(2px)}code{padding:2px 5px;color:#17654f;background:#eef5f2;border-radius:3px}.row-arrow{color:#9ba6a1;transition:all .16s ease}@media(max-width:700px){.history-toolbar{align-items:flex-start}.filter-set{width:100%}.filter-set :deep(.ant-select){width:calc(50% - 18px)}.filter-set :deep(.ant-picker){width:100%}}
</style> </style>

View File

@@ -70,6 +70,7 @@ function removeEdge(edge:SOPEdge){edges.value=edges.value.filter(item=>item!==ed
function addConditionRule(){edgeDraft.rules.push(newConditionRule())} function addConditionRule(){edgeDraft.rules.push(newConditionRule())}
function removeConditionRule(index:number){if(edgeDraft.rules.length>1)edgeDraft.rules.splice(index,1)} 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 knowledgeCardChanged(){if(selected.value)delete selected.value.config.knowledge_card_version_id}
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}`} 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():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 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 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 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))}}
@@ -105,7 +106,7 @@ onMounted(load)
<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="!canEdit" 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="!canEdit" /></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="!canEdit" 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="!canEdit" /></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="!canEdit" 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==='form'" label="表单采集字段"><a-select v-model:value="selected.config.field_keys" mode="multiple" :disabled="!canEdit" 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="!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}))" @change="knowledgeCardChanged" /><div v-if="selected.config.knowledge_card_version_id" class="snapshot-note">已发布快照知识卡版本 ID {{ selected.config.knowledge_card_version_id }}</div></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>
@@ -135,5 +136,6 @@ onMounted(load)
<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}.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} .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}
.snapshot-note{margin-top:6px;color:var(--muted);font-size:11px}
@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}} @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>