feat: complete pet SOP governance workflow

This commit is contained in:
Eric 1549169735@qq.com
2026-08-08 22:58:35 +08:00
parent 97a76250f7
commit 4b240499c2
85 changed files with 1316 additions and 269 deletions

View File

@@ -0,0 +1,51 @@
package access
import (
"git.iwork-ai.com/xdc/iqudo-top1/internal/auth"
"git.iwork-ai.com/xdc/iqudo-top1/internal/model"
"gorm.io/gorm"
)
func ScopeScenarios(query *gorm.DB, principal auth.Principal, alias string) *gorm.DB {
if auth.HasPermission(principal, "*") {
return query.Where(alias+".tenant_id = ?", principal.TenantID)
}
teamVisible := auth.HasPermission(principal, "scenario.team_view")
return query.Where(
alias+".tenant_id = ? AND ("+alias+".visibility = ? OR "+alias+".created_by = ? OR ("+alias+".visibility = ? AND ?))",
principal.TenantID, "tenant", principal.UserID, "team", teamVisible,
)
}
func CanViewScenario(db *gorm.DB, principal auth.Principal, scenarioID uint64) bool {
var count int64
query := ScopeScenarios(db.Model(&model.Scenario{}), principal, "scenarios")
if err := query.Where("scenarios.id = ? AND scenarios.status <> ?", scenarioID, "archived").Count(&count).Error; err != nil {
return false
}
return count == 1
}
func CanEditScenario(db *gorm.DB, principal auth.Principal, scenarioID uint64) bool {
if !auth.HasPermission(principal, "scenario.edit") && !auth.HasPermission(principal, "*") {
return false
}
return CanViewScenario(db, principal, scenarioID)
}
func CanViewSOP(db *gorm.DB, principal auth.Principal, sopID uint64) bool {
var count int64
query := db.Table("sops s").Joins("JOIN scenarios sc ON sc.id = s.scenario_id")
query = ScopeScenarios(query, principal, "sc")
if err := query.Where("s.id = ? AND s.tenant_id = ? AND s.status <> ?", sopID, principal.TenantID, "archived").Count(&count).Error; err != nil {
return false
}
return count == 1
}
func CanEditSOP(db *gorm.DB, principal auth.Principal, sopID uint64) bool {
if !auth.HasPermission(principal, "sop.edit") && !auth.HasPermission(principal, "*") {
return false
}
return CanViewSOP(db, principal, sopID)
}

View File

@@ -0,0 +1,16 @@
package access
import (
"testing"
"git.iwork-ai.com/xdc/iqudo-top1/internal/auth"
)
func TestScenarioVisibilityPermission(t *testing.T) {
if !auth.HasPermission(auth.Principal{Permissions: []string{"scenario.team_view"}}, "scenario.team_view") {
t.Fatal("team visibility permission should be recognized")
}
if auth.HasPermission(auth.Principal{Permissions: []string{"scenario.view"}}, "scenario.team_view") {
t.Fatal("tenant view permission must not imply team visibility")
}
}

View File

@@ -0,0 +1,19 @@
package auth
func HasPermission(principal Principal, permission string) bool {
for _, allowed := range principal.Permissions {
if allowed == "*" || allowed == permission {
return true
}
}
return false
}
func HasAnyPermission(principal Principal, permissions ...string) bool {
for _, permission := range permissions {
if HasPermission(principal, permission) {
return true
}
}
return false
}

View File

@@ -0,0 +1,24 @@
package auth
import "testing"
func TestHasPermission(t *testing.T) {
tests := []struct {
name string
permissions []string
permission string
want bool
}{
{name: "exact", permissions: []string{"sop.view"}, permission: "sop.view", want: true},
{name: "wildcard", permissions: []string{"*"}, permission: "sop.publish", want: true},
{name: "denied", permissions: []string{"sop.view"}, permission: "sop.publish", want: false},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
principal := Principal{Permissions: test.permissions}
if got := HasPermission(principal, test.permission); got != test.want {
t.Fatalf("HasPermission() = %v, want %v", got, test.want)
}
})
}
}

View File

@@ -3,6 +3,7 @@ package auth
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"time"
@@ -19,11 +20,12 @@ import (
var ErrInvalidCredentials = errors.New("invalid username or password")
type Principal struct {
UserID uint64 `json:"user_id"`
TenantID uint64 `json:"tenant_id"`
RoleCode string `json:"role_code"`
Username string `json:"username"`
DisplayName string `json:"display_name"`
UserID uint64 `json:"user_id"`
TenantID uint64 `json:"tenant_id"`
RoleCode string `json:"role_code"`
Permissions []string `json:"permissions"`
Username string `json:"username"`
DisplayName string `json:"display_name"`
}
type Claims struct {
@@ -79,7 +81,7 @@ func (s *Service) Refresh(rawToken string) (TokenPair, error) {
if err := s.db.First(&user, stored.UserID).Error; err != nil {
return TokenPair{}, err
}
principal, err := s.principalForUser(user)
principal, err := s.principalForTenant(user, stored.TenantID)
if err != nil {
return TokenPair{}, err
}
@@ -108,6 +110,18 @@ func (s *Service) ParseAccessToken(rawToken string) (Principal, error) {
return Principal{UserID: userID, TenantID: claims.TenantID, RoleCode: claims.RoleCode, Username: claims.Username, DisplayName: claims.DisplayName}, nil
}
func (s *Service) RefreshPrincipal(principal Principal) (Principal, error) {
var user model.User
if err := s.db.Where("id = ? AND status = ?", principal.UserID, "active").First(&user).Error; err != nil {
return Principal{}, ErrInvalidCredentials
}
current, err := s.principalForTenant(user, principal.TenantID)
if err != nil {
return Principal{}, ErrInvalidCredentials
}
return current, nil
}
func (s *Service) issueTokenPair(principal Principal) (TokenPair, error) {
now := time.Now()
expiresAt := now.Add(s.cfg.AccessTokenTTL)
@@ -128,16 +142,29 @@ func (s *Service) issueTokenPair(principal Principal) (TokenPair, error) {
}
func (s *Service) principalForUser(user model.User) (Principal, error) {
return s.principalForTenant(user, 0)
}
func (s *Service) principalForTenant(user model.User, tenantID uint64) (Principal, error) {
type row struct {
TenantID uint64
RoleCode string
TenantID uint64
RoleCode string
Permissions datatypes.JSON
}
var membership row
err := s.db.Table("tenant_members tm").Select("tm.tenant_id, r.code AS role_code").Joins("JOIN roles r ON r.id = tm.role_id").Where("tm.user_id = ? AND tm.status = ?", user.ID, "active").First(&membership).Error
query := s.db.Table("tenant_members tm").Select("tm.tenant_id, r.code AS role_code, r.permissions").Joins("JOIN roles r ON r.id = tm.role_id").Where("tm.user_id = ? AND tm.status = ?", user.ID, "active")
if tenantID != 0 {
query = query.Where("tm.tenant_id = ?", tenantID)
}
err := query.Order("tm.id").First(&membership).Error
if err != nil {
return Principal{}, err
}
return Principal{UserID: user.ID, TenantID: membership.TenantID, RoleCode: membership.RoleCode, Username: user.Username, DisplayName: user.DisplayName}, nil
var permissions []string
if err := json.Unmarshal(membership.Permissions, &permissions); err != nil {
return Principal{}, fmt.Errorf("decode role permissions: %w", err)
}
return Principal{UserID: user.ID, TenantID: membership.TenantID, RoleCode: membership.RoleCode, Permissions: permissions, Username: user.Username, DisplayName: user.DisplayName}, nil
}
func Seed(db *gorm.DB, cfg config.SeedConfig) error {
@@ -149,10 +176,23 @@ func Seed(db *gorm.DB, cfg config.SeedConfig) error {
if err := tx.Where("slug = ?", "default").FirstOrCreate(&tenant, model.Tenant{Name: "默认企业", Slug: "default", Status: "active"}).Error; err != nil {
return err
}
permissions := datatypes.JSON([]byte(`["*"]`))
var role model.Role
if err := tx.Where("tenant_id = ? AND code = ?", tenant.ID, "admin").FirstOrCreate(&role, model.Role{TenantID: tenant.ID, Name: "管理员", Code: "admin", Permissions: permissions}).Error; err != nil {
return err
roleDefinitions := []struct {
Name, Code string
Permissions []string
}{
{Name: "管理员", Code: "admin", Permissions: []string{"*"}},
{Name: "SOP 编辑者", Code: "editor", Permissions: []string{"dashboard.view", "scenario.view", "scenario.team_view", "scenario.edit", "sop.view", "sop.edit", "sop.submit_review", "knowledge.view", "knowledge.edit", "runs.view_all"}},
{Name: "审核者", Code: "reviewer", Permissions: []string{"dashboard.view", "scenario.view", "scenario.team_view", "sop.view", "sop.review", "sop.publish", "knowledge.view", "runs.view_all"}},
{Name: "一线执行者", Code: "operator", Permissions: []string{"dashboard.view", "scenario.view", "sop.execute", "knowledge.view", "runs.view_own", "runs.feedback"}},
}
roles := make(map[string]model.Role, len(roleDefinitions))
for _, definition := range roleDefinitions {
permissions, _ := json.Marshal(definition.Permissions)
var role model.Role
if err := tx.Where("tenant_id = ? AND code = ?", tenant.ID, definition.Code).Assign(model.Role{Name: definition.Name, Permissions: datatypes.JSON(permissions)}).FirstOrCreate(&role, model.Role{TenantID: tenant.ID, Code: definition.Code}).Error; err != nil {
return err
}
roles[definition.Code] = role
}
var user model.User
err := tx.Where("username = ?", cfg.AdminUsername).First(&user).Error
@@ -169,7 +209,7 @@ func Seed(db *gorm.DB, cfg config.SeedConfig) error {
return err
}
var member model.TenantMember
return tx.Where("tenant_id = ? AND user_id = ?", tenant.ID, user.ID).FirstOrCreate(&member, model.TenantMember{TenantID: tenant.ID, UserID: user.ID, RoleID: role.ID, Status: "active"}).Error
return tx.Where("tenant_id = ? AND user_id = ?", tenant.ID, user.ID).FirstOrCreate(&member, model.TenantMember{TenantID: tenant.ID, UserID: user.ID, RoleID: roles["admin"].ID, Status: "active"}).Error
})
}

View File

@@ -3,6 +3,7 @@ package dashboard
import (
"net/http"
"git.iwork-ai.com/xdc/iqudo-top1/internal/access"
"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/response"
@@ -15,26 +16,46 @@ type Handler struct{ db *gorm.DB }
func NewHandler(db *gorm.DB) *Handler { return &Handler{db: db} }
func (h *Handler) Summary(c *gin.Context) {
p, _ := auth.PrincipalFromContext(c)
principal, _ := auth.PrincipalFromContext(c)
counts := map[string]int64{}
queries := []struct {
key string
model interface{}
where string
args []interface{}
}{
{key: "scenarios", model: &model.Scenario{}, where: "tenant_id = ? AND status <> ?", args: []interface{}{p.TenantID, "archived"}},
{key: "published_sops", model: &model.SOP{}, where: "tenant_id = ? AND status = ?", args: []interface{}{p.TenantID, "published"}},
{key: "runs", model: &model.SOPRun{}, where: "tenant_id = ?", args: []interface{}{p.TenantID}},
{key: "completed_runs", model: &model.SOPRun{}, where: "tenant_id = ? AND status = ?", args: []interface{}{p.TenantID, "completed"}},
scenarioQuery := access.ScopeScenarios(h.db.Model(&model.Scenario{}), principal, "scenarios").Where("scenarios.status <> ?", "archived")
var scenarios int64
if err := scenarioQuery.Count(&scenarios).Error; err != nil {
queryFailed(c)
return
}
for _, query := range queries {
var count int64
if err := h.db.Model(query.model).Where(query.where, query.args...).Count(&count).Error; err != nil {
response.Error(c, http.StatusInternalServerError, "QUERY_FAILED", "查询统计数据失败")
return
}
counts[query.key] = count
counts["scenarios"] = scenarios
sopQuery := h.db.Table("sops s").Joins("JOIN scenarios sc ON sc.id = s.scenario_id")
sopQuery = access.ScopeScenarios(sopQuery, principal, "sc").Where("s.status = ?", "published")
var publishedSOPs int64
if err := sopQuery.Count(&publishedSOPs).Error; err != nil {
queryFailed(c)
return
}
counts["published_sops"] = publishedSOPs
runQuery := h.db.Model(&model.SOPRun{}).Where("tenant_id = ?", principal.TenantID)
if !auth.HasPermission(principal, "runs.view_all") && !auth.HasPermission(principal, "*") {
runQuery = runQuery.Where("operator_id = ?", principal.UserID)
}
var runs int64
if err := runQuery.Count(&runs).Error; err != nil {
queryFailed(c)
return
}
counts["runs"] = runs
var completedRuns int64
if err := runQuery.Where("status = ?", "completed").Count(&completedRuns).Error; err != nil {
queryFailed(c)
return
}
counts["completed_runs"] = completedRuns
response.OK(c, counts)
}
func queryFailed(c *gin.Context) {
response.Error(c, http.StatusInternalServerError, "QUERY_FAILED", "查询统计数据失败")
}

View File

@@ -10,6 +10,7 @@ import (
"git.iwork-ai.com/xdc/iqudo-top1/internal/auth"
"git.iwork-ai.com/xdc/iqudo-top1/internal/dashboard"
"git.iwork-ai.com/xdc/iqudo-top1/internal/knowledge"
"git.iwork-ai.com/xdc/iqudo-top1/internal/member"
"git.iwork-ai.com/xdc/iqudo-top1/internal/middleware"
runhandler "git.iwork-ai.com/xdc/iqudo-top1/internal/run"
"git.iwork-ai.com/xdc/iqudo-top1/internal/scenario"
@@ -31,6 +32,7 @@ func New(db *gorm.DB, authService *auth.Service, frontend fs.FS, log *zap.Logger
sopHandler := sop.NewHandler(db)
runHandler := runhandler.NewHandler(db)
knowledgeHandler := knowledge.NewHandler(db)
memberHandler := member.NewHandler(db)
dashboardHandler := dashboard.NewHandler(db)
api := router.Group("/api/v1")
@@ -43,39 +45,45 @@ func New(db *gorm.DB, authService *auth.Service, frontend fs.FS, log *zap.Logger
protected := api.Group("")
protected.Use(middleware.Authenticate(authService))
protected.GET("/auth/me", authHandler.Me)
protected.GET("/dashboard/summary", dashboardHandler.Summary)
protected.GET("/members", middleware.RequirePermission("member.manage"), memberHandler.List)
protected.POST("/members", middleware.RequirePermission("member.manage"), memberHandler.Create)
protected.PUT("/members/:id", middleware.RequirePermission("member.manage"), memberHandler.Update)
protected.GET("/dashboard/summary", middleware.RequirePermission("dashboard.view"), dashboardHandler.Summary)
protected.GET("/scenarios", scenarioHandler.List)
protected.POST("/scenarios", scenarioHandler.Create)
protected.GET("/scenarios/:id", scenarioHandler.Get)
protected.PUT("/scenarios/:id", scenarioHandler.Update)
protected.DELETE("/scenarios/:id", scenarioHandler.Archive)
protected.POST("/scenarios/:id/fields", scenarioHandler.CreateField)
protected.PUT("/scenario-fields/:fieldId", scenarioHandler.UpdateField)
protected.DELETE("/scenario-fields/:fieldId", scenarioHandler.DeleteField)
protected.GET("/scenarios", middleware.RequirePermission("scenario.view"), scenarioHandler.List)
protected.POST("/scenarios", middleware.RequirePermission("scenario.edit"), scenarioHandler.Create)
protected.GET("/scenarios/:id", middleware.RequirePermission("scenario.view"), scenarioHandler.Get)
protected.PUT("/scenarios/:id", middleware.RequirePermission("scenario.edit"), scenarioHandler.Update)
protected.DELETE("/scenarios/:id", middleware.RequirePermission("scenario.edit"), scenarioHandler.Archive)
protected.POST("/scenarios/:id/fields", middleware.RequirePermission("scenario.edit"), scenarioHandler.CreateField)
protected.PUT("/scenario-fields/:fieldId", middleware.RequirePermission("scenario.edit"), scenarioHandler.UpdateField)
protected.DELETE("/scenario-fields/:fieldId", middleware.RequirePermission("scenario.edit"), scenarioHandler.DeleteField)
protected.GET("/sops", sopHandler.List)
protected.GET("/scenarios/:id/sops", sopHandler.List)
protected.POST("/scenarios/:id/sops", sopHandler.Create)
protected.GET("/sops/:id", sopHandler.Get)
protected.PUT("/sops/:id/draft", sopHandler.SaveGraph)
protected.POST("/sops/:id/validate", sopHandler.Validate)
protected.POST("/sops/:id/submit-review", sopHandler.SubmitReview)
protected.POST("/sops/:id/publish", sopHandler.Publish)
protected.POST("/sops/:id/offline", sopHandler.Offline)
protected.POST("/sops/:id/versions", sopHandler.CreateVersion)
protected.GET("/sops", middleware.RequirePermission("sop.view"), sopHandler.List)
protected.GET("/scenarios/:id/sops", middleware.RequirePermission("sop.view"), sopHandler.List)
protected.POST("/scenarios/:id/sops", middleware.RequirePermission("sop.edit"), sopHandler.Create)
protected.GET("/sops/:id", middleware.RequirePermission("sop.view"), sopHandler.Get)
protected.PUT("/sops/:id/draft", middleware.RequirePermission("sop.edit"), sopHandler.SaveGraph)
protected.POST("/sops/:id/validate", middleware.RequireAnyPermission("sop.view", "sop.edit"), sopHandler.Validate)
protected.POST("/sops/:id/submit-review", middleware.RequirePermission("sop.submit_review"), sopHandler.SubmitReview)
protected.POST("/sops/:id/publish", middleware.RequirePermission("sop.publish"), sopHandler.Publish)
protected.POST("/sops/:id/reject", middleware.RequirePermission("sop.review"), sopHandler.Reject)
protected.POST("/sops/:id/offline", middleware.RequirePermission("sop.publish"), sopHandler.Offline)
protected.POST("/sops/:id/versions", middleware.RequirePermission("sop.edit"), sopHandler.CreateVersion)
protected.GET("/reviews", middleware.RequirePermission("sop.review"), sopHandler.Reviews)
protected.GET("/published-sops", runHandler.PublishedSOPs)
protected.GET("/runs", runHandler.List)
protected.POST("/runs", runHandler.Start)
protected.GET("/runs/:id", runHandler.Get)
protected.POST("/runs/:id/answer", runHandler.Answer)
protected.POST("/runs/:id/finish", runHandler.Finish)
protected.POST("/runs/:id/feedback", runHandler.Feedback)
protected.GET("/published-sops", middleware.RequirePermission("sop.execute"), runHandler.PublishedSOPs)
protected.GET("/runs", middleware.RequireAnyPermission("runs.view_all", "runs.view_own"), runHandler.List)
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/detail", middleware.RequireAnyPermission("runs.view_all", "runs.view_own"), runHandler.Detail)
protected.POST("/runs/:id/answer", middleware.RequirePermission("sop.execute"), runHandler.Answer)
protected.POST("/runs/:id/finish", middleware.RequirePermission("sop.execute"), runHandler.Finish)
protected.POST("/runs/:id/feedback", middleware.RequirePermission("runs.feedback"), runHandler.Feedback)
protected.GET("/knowledge-cards", knowledgeHandler.List)
protected.POST("/knowledge-cards", knowledgeHandler.Create)
protected.DELETE("/knowledge-cards/:id", knowledgeHandler.Delete)
protected.GET("/knowledge-cards", middleware.RequirePermission("knowledge.view"), knowledgeHandler.List)
protected.POST("/knowledge-cards", middleware.RequirePermission("knowledge.edit"), knowledgeHandler.Create)
protected.DELETE("/knowledge-cards/:id", middleware.RequirePermission("knowledge.edit"), knowledgeHandler.Delete)
router.NoRoute(spaHandler(frontend))
return router

View File

@@ -5,6 +5,7 @@ import (
"net/http"
"strconv"
"git.iwork-ai.com/xdc/iqudo-top1/internal/access"
"git.iwork-ai.com/xdc/iqudo-top1/internal/audit"
"git.iwork-ai.com/xdc/iqudo-top1/internal/auth"
"git.iwork-ai.com/xdc/iqudo-top1/internal/model"
@@ -30,8 +31,10 @@ func (h *Handler) List(c *gin.Context) {
model.KnowledgeCard
Content datatypes.JSON `json:"content"`
}
var items []row
err := h.db.Table("knowledge_cards kc").Select("kc.*, kcv.content").Joins("LEFT JOIN knowledge_card_versions kcv ON kcv.knowledge_card_id = kc.id AND kcv.status = ?", "published").Where("kc.tenant_id = ?", p.TenantID).Order("kc.updated_at DESC").Scan(&items).Error
items := make([]row, 0)
query := h.db.Table("knowledge_cards kc").Select("kc.*, kcv.content").Joins("JOIN scenarios sc ON sc.id = kc.scenario_id").Joins("LEFT JOIN knowledge_card_versions kcv ON kcv.knowledge_card_id = kc.id AND kcv.status = ?", "published")
query = access.ScopeScenarios(query, p, "sc")
err := query.Where("kc.tenant_id = ? AND kc.status <> ?", p.TenantID, "archived").Order("kc.updated_at DESC").Scan(&items).Error
if err != nil {
response.Error(c, http.StatusInternalServerError, "QUERY_FAILED", "查询知识卡失败")
return
@@ -46,6 +49,10 @@ func (h *Handler) Create(c *gin.Context) {
response.Error(c, http.StatusBadRequest, "INVALID_ARGUMENT", "知识卡内容不完整")
return
}
if !access.CanEditScenario(h.db, p, body.ScenarioID) {
response.Error(c, http.StatusNotFound, "NOT_FOUND", "场景不存在或不可编辑")
return
}
content, _ := json.Marshal(body.Content)
var card model.KnowledgeCard
err := h.db.Transaction(func(tx *gorm.DB) error {
@@ -70,10 +77,29 @@ func (h *Handler) Delete(c *gin.Context) {
response.Error(c, http.StatusBadRequest, "INVALID_ID", "知识卡 ID 不正确")
return
}
var card model.KnowledgeCard
if err := h.db.Where("id = ? AND tenant_id = ?", id, p.TenantID).First(&card).Error; err != nil || !access.CanEditScenario(h.db, p, card.ScenarioID) {
response.Error(c, http.StatusNotFound, "NOT_FOUND", "知识卡不存在或不可归档")
return
}
var references int64
err = h.db.Table("sop_nodes n").Joins("JOIN sop_versions sv ON sv.id = n.sop_version_id").Joins("JOIN sops s ON s.id = sv.sop_id").Where(
"n.tenant_id = ? AND s.scenario_id = ? AND sv.status IN ? AND n.type = ? AND JSON_UNQUOTE(JSON_EXTRACT(n.config, '$.knowledge_card_id')) = ?",
p.TenantID, card.ScenarioID, []string{"published", "offline", "superseded"}, "knowledge", strconv.FormatUint(id, 10),
).Count(&references).Error
if err != nil {
response.Error(c, http.StatusInternalServerError, "QUERY_FAILED", "检查知识卡引用失败")
return
}
if references > 0 {
response.Error(c, http.StatusConflict, "KNOWLEDGE_IN_USE", "知识卡已被发布版本引用,不能归档")
return
}
result := h.db.Model(&model.KnowledgeCard{}).Where("id = ? AND tenant_id = ?", id, p.TenantID).Update("status", "archived")
if result.Error != nil || result.RowsAffected == 0 {
response.Error(c, http.StatusNotFound, "NOT_FOUND", "知识卡不存在")
return
}
_ = audit.Record(h.db, p, "archive", "knowledge_card", id, nil)
response.OK(c, gin.H{"id": id})
}

127
internal/member/handler.go Normal file
View File

@@ -0,0 +1,127 @@
package member
import (
"net/http"
"strconv"
"git.iwork-ai.com/xdc/iqudo-top1/internal/audit"
"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/response"
"github.com/gin-gonic/gin"
"golang.org/x/crypto/bcrypt"
"gorm.io/gorm"
)
type Handler struct{ db *gorm.DB }
func NewHandler(db *gorm.DB) *Handler { return &Handler{db: db} }
type MemberItem struct {
ID uint64 `json:"id"`
UserID uint64 `json:"user_id"`
Username string `json:"username"`
DisplayName string `json:"display_name"`
RoleCode string `json:"role_code"`
RoleName string `json:"role_name"`
Status string `json:"status"`
}
func (h *Handler) List(c *gin.Context) {
principal, _ := auth.PrincipalFromContext(c)
items := make([]MemberItem, 0)
err := h.db.Table("tenant_members tm").Select("tm.id, tm.user_id, u.username, u.display_name, r.code AS role_code, r.name AS role_name, tm.status").Joins("JOIN users u ON u.id = tm.user_id").Joins("JOIN roles r ON r.id = tm.role_id").Where("tm.tenant_id = ?", principal.TenantID).Order("tm.id").Scan(&items).Error
if err != nil {
response.Error(c, http.StatusInternalServerError, "QUERY_FAILED", "查询企业成员失败")
return
}
roles := make([]model.Role, 0)
if err := h.db.Where("tenant_id = ?", principal.TenantID).Order("id").Find(&roles).Error; err != nil {
response.Error(c, http.StatusInternalServerError, "QUERY_FAILED", "查询角色失败")
return
}
response.OK(c, gin.H{"items": items, "roles": roles, "total": len(items)})
}
func (h *Handler) Create(c *gin.Context) {
principal, _ := auth.PrincipalFromContext(c)
var input struct {
Username string `json:"username" binding:"required,min=2,max=64"`
Password string `json:"password" binding:"required,min=6,max=128"`
DisplayName string `json:"display_name" binding:"required,max=128"`
RoleCode string `json:"role_code" binding:"required,max=64"`
}
if err := c.ShouldBindJSON(&input); err != nil {
response.Error(c, http.StatusBadRequest, "INVALID_ARGUMENT", "成员信息不完整")
return
}
var role model.Role
if err := h.db.Where("tenant_id = ? AND code = ?", principal.TenantID, input.RoleCode).First(&role).Error; err != nil {
response.Error(c, http.StatusBadRequest, "INVALID_ROLE", "角色不存在")
return
}
hash, err := bcrypt.GenerateFromPassword([]byte(input.Password), bcrypt.DefaultCost)
if err != nil {
response.Error(c, http.StatusInternalServerError, "CREATE_FAILED", "创建成员失败")
return
}
var user model.User
var membership model.TenantMember
err = h.db.Transaction(func(tx *gorm.DB) error {
user = model.User{Username: input.Username, PasswordHash: string(hash), DisplayName: input.DisplayName, Status: "active"}
if err := tx.Create(&user).Error; err != nil {
return err
}
membership = model.TenantMember{TenantID: principal.TenantID, UserID: user.ID, RoleID: role.ID, Status: "active"}
return tx.Create(&membership).Error
})
if err != nil {
response.Error(c, http.StatusConflict, "MEMBER_EXISTS", "用户名已存在")
return
}
_ = audit.Record(h.db, principal, "create", "tenant_member", membership.ID, gin.H{"username": input.Username, "role_code": input.RoleCode})
response.Created(c, gin.H{"id": membership.ID, "user_id": user.ID})
}
func (h *Handler) Update(c *gin.Context) {
principal, _ := auth.PrincipalFromContext(c)
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil || id == 0 {
response.Error(c, http.StatusBadRequest, "INVALID_ID", "成员 ID 不正确")
return
}
var input struct {
RoleCode string `json:"role_code" binding:"required,max=64"`
Status string `json:"status" binding:"required,oneof=active disabled"`
}
if err := c.ShouldBindJSON(&input); err != nil {
response.Error(c, http.StatusBadRequest, "INVALID_ARGUMENT", "成员配置不正确")
return
}
var membership model.TenantMember
if err := h.db.Where("id = ? AND tenant_id = ?", id, principal.TenantID).First(&membership).Error; err != nil {
response.Error(c, http.StatusNotFound, "NOT_FOUND", "成员不存在")
return
}
if membership.UserID == principal.UserID {
response.Error(c, http.StatusConflict, "SELF_UPDATE_FORBIDDEN", "不能修改自己的角色或状态")
return
}
var role model.Role
if err := h.db.Where("tenant_id = ? AND code = ?", principal.TenantID, input.RoleCode).First(&role).Error; err != nil {
response.Error(c, http.StatusBadRequest, "INVALID_ROLE", "角色不存在")
return
}
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 err
}
return tx.Model(&model.User{}).Where("id = ?", membership.UserID).Update("status", input.Status).Error
})
if err != nil {
response.Error(c, http.StatusInternalServerError, "UPDATE_FAILED", "更新成员失败")
return
}
_ = audit.Record(h.db, principal, "update", "tenant_member", id, input)
response.OK(c, gin.H{"id": id, "role_code": input.RoleCode, "status": input.Status})
}

View File

@@ -63,7 +63,31 @@ func Authenticate(service *auth.Service) gin.HandlerFunc {
response.Error(c, http.StatusUnauthorized, "INVALID_TOKEN", "登录状态已失效")
return
}
principal, err = service.RefreshPrincipal(principal)
if err != nil {
response.Error(c, http.StatusUnauthorized, "INVALID_MEMBERSHIP", "账号或企业成员身份已失效")
return
}
auth.SetPrincipal(c, principal)
c.Next()
}
}
func RequireAnyPermission(permissions ...string) gin.HandlerFunc {
return func(c *gin.Context) {
principal, ok := auth.PrincipalFromContext(c)
if !ok {
response.Error(c, http.StatusUnauthorized, "UNAUTHORIZED", "请先登录")
return
}
if !auth.HasAnyPermission(principal, permissions...) {
response.Error(c, http.StatusForbidden, "FORBIDDEN", "没有执行该操作的权限")
return
}
c.Next()
}
}
func RequirePermission(permission string) gin.HandlerFunc {
return RequireAnyPermission(permission)
}

66
internal/run/detail.go Normal file
View File

@@ -0,0 +1,66 @@
package run
import (
"net/http"
"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/response"
"github.com/gin-gonic/gin"
"gorm.io/datatypes"
)
type DetailHeader struct {
model.SOPRun
SOPName string `json:"sop_name"`
ScenarioName string `json:"scenario_name"`
Version int `json:"version"`
OperatorName string `json:"operator_name"`
}
type DetailEvent struct {
model.SOPRunEvent
NodeTitle string `json:"node_title"`
NodeType string `json:"node_type"`
NodeContent string `json:"node_content"`
}
type DetailFeedback struct {
model.SOPFeedback
UserName string `json:"user_name"`
}
func (h *Handler) Detail(c *gin.Context) {
principal, _ := auth.PrincipalFromContext(c)
id, ok := runID(c)
if !ok {
return
}
var header DetailHeader
err := h.db.Table("sop_runs r").Select(
"r.*, s.name AS sop_name, sc.name AS scenario_name, sv.version, u.display_name AS operator_name",
).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").Where("r.id = ? AND r.tenant_id = ?", id, principal.TenantID).Scan(&header).Error
if err != nil || header.ID == 0 || !canViewRun(principal, header.SOPRun) {
response.Error(c, http.StatusNotFound, "NOT_FOUND", "执行记录不存在")
return
}
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 {
response.Error(c, http.StatusInternalServerError, "QUERY_FAILED", "查询执行事件失败")
return
}
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 {
response.Error(c, http.StatusInternalServerError, "QUERY_FAILED", "查询执行反馈失败")
return
}
fields := make([]model.ScenarioField, 0)
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 = ?", header.SOPID, principal.TenantID).Order("sf.sort_order, sf.id").Find(&fields).Error; err != nil {
response.Error(c, http.StatusInternalServerError, "QUERY_FAILED", "查询场景字段失败")
return
}
if header.Answers == nil {
header.Answers = datatypes.JSON([]byte(`{}`))
}
response.OK(c, gin.H{"run": header, "events": events, "feedback": feedback, "fields": fields})
}

View File

@@ -8,6 +8,7 @@ import (
"strconv"
"time"
"git.iwork-ai.com/xdc/iqudo-top1/internal/access"
"git.iwork-ai.com/xdc/iqudo-top1/internal/audit"
"git.iwork-ai.com/xdc/iqudo-top1/internal/auth"
"git.iwork-ai.com/xdc/iqudo-top1/internal/model"
@@ -36,8 +37,10 @@ func (h *Handler) PublishedSOPs(c *gin.Context) {
ScenarioName string `json:"scenario_name"`
Version int `json:"version"`
}
var items []item
err := h.db.Table("sops s").Select("s.id, s.name, s.description, s.scenario_id, sc.name AS scenario_name, sv.version").Joins("JOIN scenarios sc ON sc.id = s.scenario_id").Joins("JOIN sop_versions sv ON sv.sop_id = s.id AND sv.status = ?", "published").Where("s.tenant_id = ? AND s.status = ?", p.TenantID, "published").Order("s.updated_at DESC").Scan(&items).Error
items := make([]item, 0)
query := h.db.Table("sops s").Select("s.id, s.name, s.description, s.scenario_id, sc.name AS scenario_name, sv.version").Joins("JOIN scenarios sc ON sc.id = s.scenario_id").Joins("JOIN sop_versions sv ON sv.sop_id = s.id AND sv.status = ?", "published")
query = access.ScopeScenarios(query, p, "sc")
err := query.Where("s.tenant_id = ? AND s.status = ?", p.TenantID, "published").Order("s.updated_at DESC").Scan(&items).Error
if err != nil {
response.Error(c, http.StatusInternalServerError, "QUERY_FAILED", "查询可执行 SOP 失败")
return
@@ -54,6 +57,10 @@ func (h *Handler) Start(c *gin.Context) {
response.Error(c, http.StatusBadRequest, "INVALID_ARGUMENT", "请选择要执行的 SOP")
return
}
if !access.CanViewSOP(h.db, p, input.SOPID) {
response.Error(c, http.StatusNotFound, "NOT_FOUND", "没有可执行的已发布 SOP")
return
}
var version model.SOPVersion
if err := h.db.Where("sop_id = ? AND tenant_id = ? AND status = ?", input.SOPID, p.TenantID, "published").Order("version DESC").First(&version).Error; err != nil {
response.Error(c, http.StatusNotFound, "NOT_FOUND", "没有可执行的已发布版本")
@@ -85,6 +92,10 @@ func (h *Handler) Get(c *gin.Context) {
response.Error(c, http.StatusNotFound, "NOT_FOUND", "执行记录不存在")
return
}
if !canViewRun(p, item) {
response.Error(c, http.StatusNotFound, "NOT_FOUND", "执行记录不存在")
return
}
h.respondRun(c, item)
}
@@ -94,12 +105,34 @@ func (h *Handler) List(c *gin.Context) {
model.SOPRun
SOPName string `json:"sop_name"`
}
var items []row
if err := h.db.Table("sop_runs r").Select("r.*, s.name AS sop_name").Joins("JOIN sops s ON s.id = r.sop_id").Where("r.tenant_id = ?", p.TenantID).Order("r.created_at DESC").Limit(100).Scan(&items).Error; err != nil {
page, pageSize := pagination(c)
query := scopeRuns(h.db.Table("sop_runs r").Joins("JOIN sops s ON s.id = r.sop_id"), p, "r")
if status := c.Query("status"); status != "" {
query = query.Where("r.status = ?", status)
}
if result := c.Query("result"); result != "" {
query = query.Where("r.result = ?", result)
}
if sopID := c.Query("sop_id"); sopID != "" {
query = query.Where("r.sop_id = ?", sopID)
}
if startedFrom := c.Query("started_from"); startedFrom != "" {
query = query.Where("r.started_at >= ?", startedFrom)
}
if startedTo := c.Query("started_to"); startedTo != "" {
query = query.Where("r.started_at <= ?", startedTo)
}
var total int64
if err := query.Count(&total).Error; err != nil {
response.Error(c, http.StatusInternalServerError, "QUERY_FAILED", "查询执行记录失败")
return
}
response.OK(c, gin.H{"items": items, "total": len(items)})
items := make([]row, 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 {
response.Error(c, http.StatusInternalServerError, "QUERY_FAILED", "查询执行记录失败")
return
}
response.OK(c, gin.H{"items": items, "total": total, "page": page, "page_size": pageSize})
}
func (h *Handler) Answer(c *gin.Context) {
@@ -124,6 +157,9 @@ func (h *Handler) Answer(c *gin.Context) {
if updated.Status != "running" {
return errors.New("run is not active")
}
if !canOperateRun(p, updated) {
return gorm.ErrRecordNotFound
}
if input.NodeKey != "" && input.NodeKey != updated.CurrentNodeKey {
return errors.New("当前步骤已经变化,请刷新后重试")
}
@@ -131,7 +167,7 @@ func (h *Handler) Answer(c *gin.Context) {
if err := tx.Where("sop_version_id = ? AND node_key = ? AND tenant_id = ?", updated.SOPVersionID, updated.CurrentNodeKey, p.TenantID).First(&currentNode).Error; err != nil {
return err
}
var fields []model.ScenarioField
fields := make([]model.ScenarioField, 0)
if err := tx.Table("scenario_fields sf").Joins("JOIN sops s ON s.scenario_id = sf.scenario_id").Where("s.id = ? AND sf.tenant_id = ?", updated.SOPID, p.TenantID).Order("sf.sort_order, sf.id").Find(&fields).Error; err != nil {
return err
}
@@ -210,8 +246,17 @@ func (h *Handler) Finish(c *gin.Context) {
response.Error(c, http.StatusBadRequest, "INVALID_ARGUMENT", "请选择执行结果")
return
}
var run model.SOPRun
if err := h.db.Where("id = ? AND tenant_id = ?", id, p.TenantID).First(&run).Error; err != nil || !canOperateRun(p, run) {
response.Error(c, http.StatusNotFound, "NOT_FOUND", "执行记录不存在")
return
}
if run.Status != "running" {
response.Error(c, http.StatusConflict, "RUN_COMPLETED", "执行记录已经结束")
return
}
now := time.Now()
result := h.db.Model(&model.SOPRun{}).Where("id = ? AND tenant_id = ?", id, p.TenantID).Updates(map[string]interface{}{"status": "completed", "result": input.Result, "completed_at": &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})
if result.Error != nil || result.RowsAffected == 0 {
response.Error(c, http.StatusNotFound, "NOT_FOUND", "执行记录不存在")
return
@@ -234,11 +279,21 @@ func (h *Handler) Feedback(c *gin.Context) {
response.Error(c, http.StatusBadRequest, "INVALID_ARGUMENT", "反馈内容不正确")
return
}
var run model.SOPRun
if err := h.db.Where("id = ? AND tenant_id = ?", id, p.TenantID).First(&run).Error; err != nil || !canViewRun(p, run) {
response.Error(c, http.StatusNotFound, "NOT_FOUND", "执行记录不存在")
return
}
if run.Status != "completed" {
response.Error(c, http.StatusConflict, "RUN_NOT_COMPLETED", "执行完成后才能提交反馈")
return
}
item := model.SOPFeedback{TenantID: p.TenantID, RunID: id, UserID: p.UserID, Score: input.Score, Comment: input.Comment}
if err := h.db.Create(&item).Error; err != nil {
response.Error(c, http.StatusConflict, "FEEDBACK_EXISTS", "该执行记录已经提交反馈")
return
}
_ = audit.Record(h.db, p, "feedback", "sop_run", id, gin.H{"score": input.Score})
response.Created(c, item)
}
@@ -248,7 +303,7 @@ func (h *Handler) respondRun(c *gin.Context, item model.SOPRun) {
response.Error(c, http.StatusInternalServerError, "NODE_NOT_FOUND", "当前流程节点不存在")
return
}
var fields []model.ScenarioField
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)
response.OK(c, gin.H{"run": item, "node": node, "fields": fields})
}
@@ -261,3 +316,37 @@ func runID(c *gin.Context) (uint64, bool) {
}
return id, true
}
func scopeRuns(query *gorm.DB, principal auth.Principal, alias string) *gorm.DB {
query = query.Where(alias+".tenant_id = ?", principal.TenantID)
if auth.HasPermission(principal, "runs.view_all") || auth.HasPermission(principal, "*") {
return query
}
return query.Where(alias+".operator_id = ?", principal.UserID)
}
func canViewRun(principal auth.Principal, run model.SOPRun) bool {
if run.TenantID != principal.TenantID {
return false
}
return run.OperatorID == principal.UserID || auth.HasPermission(principal, "runs.view_all") || auth.HasPermission(principal, "*")
}
func canOperateRun(principal auth.Principal, run model.SOPRun) bool {
return run.TenantID == principal.TenantID && (run.OperatorID == principal.UserID || auth.HasPermission(principal, "*"))
}
func pagination(c *gin.Context) (int, int) {
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
if page < 1 {
page = 1
}
if pageSize < 1 {
pageSize = 20
}
if pageSize > 100 {
pageSize = 100
}
return page, pageSize
}

View File

@@ -7,6 +7,7 @@ import (
"regexp"
"strconv"
"git.iwork-ai.com/xdc/iqudo-top1/internal/access"
"git.iwork-ai.com/xdc/iqudo-top1/internal/audit"
"git.iwork-ai.com/xdc/iqudo-top1/internal/auth"
"git.iwork-ai.com/xdc/iqudo-top1/internal/model"
@@ -47,20 +48,20 @@ var fieldKeyPattern = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9_]*$`)
func (h *Handler) List(c *gin.Context) {
p, _ := auth.PrincipalFromContext(c)
var items []model.Scenario
query := h.db.Where("tenant_id = ? AND status <> ?", p.TenantID, "archived")
items := make([]model.Scenario, 0)
query := access.ScopeScenarios(h.db.Model(&model.Scenario{}), p, "scenarios").Where("scenarios.status <> ?", "archived")
if keyword := c.Query("keyword"); keyword != "" {
query = query.Where("name LIKE ? OR industry LIKE ?", "%"+keyword+"%", "%"+keyword+"%")
query = query.Where("scenarios.name LIKE ? OR scenarios.industry LIKE ?", "%"+keyword+"%", "%"+keyword+"%")
}
if status := c.Query("status"); status != "" {
query = query.Where("status = ?", status)
}
var total int64
if err := query.Model(&model.Scenario{}).Count(&total).Error; err != nil {
if err := query.Count(&total).Error; err != nil {
response.Error(c, http.StatusInternalServerError, "QUERY_FAILED", "查询场景失败")
return
}
if err := query.Order("updated_at DESC").Find(&items).Error; err != nil {
if err := query.Order("scenarios.updated_at DESC").Find(&items).Error; err != nil {
response.Error(c, http.StatusInternalServerError, "QUERY_FAILED", "查询场景失败")
return
}
@@ -93,15 +94,21 @@ func (h *Handler) Get(c *gin.Context) {
if !ok {
return
}
if !access.CanViewScenario(h.db, p, id) {
response.Error(c, http.StatusNotFound, "NOT_FOUND", "场景不存在")
return
}
var item model.Scenario
if err := h.db.Where("id = ? AND tenant_id = ?", id, p.TenantID).First(&item).Error; err != nil {
notFound(c, err, "场景不存在")
return
}
var fields []model.ScenarioField
var sops []model.SOP
fields := make([]model.ScenarioField, 0)
sops := make([]model.SOP, 0)
h.db.Where("scenario_id = ? AND tenant_id = ?", id, p.TenantID).Order("sort_order, id").Find(&fields)
h.db.Where("scenario_id = ? AND tenant_id = ?", id, p.TenantID).Order("updated_at DESC").Find(&sops)
if auth.HasPermission(p, "sop.view") {
h.db.Where("scenario_id = ? AND tenant_id = ?", id, p.TenantID).Order("updated_at DESC").Find(&sops)
}
response.OK(c, gin.H{"scenario": item, "fields": fields, "sops": sops})
}
@@ -111,12 +118,20 @@ func (h *Handler) Update(c *gin.Context) {
if !ok {
return
}
if !access.CanEditScenario(h.db, p, id) {
response.Error(c, http.StatusNotFound, "NOT_FOUND", "场景不存在或不可编辑")
return
}
var input scenarioInput
if err := c.ShouldBindJSON(&input); err != nil {
response.Error(c, http.StatusBadRequest, "INVALID_ARGUMENT", "场景信息不完整")
return
}
updates := map[string]interface{}{"name": input.Name, "industry": input.Industry, "role_name": input.RoleName, "goal": input.Goal, "trigger_text": input.TriggerText, "visibility": input.Visibility}
visibility := input.Visibility
if visibility == "" {
visibility = "tenant"
}
updates := map[string]interface{}{"name": input.Name, "industry": input.Industry, "role_name": input.RoleName, "goal": input.Goal, "trigger_text": input.TriggerText, "visibility": visibility}
result := h.db.Model(&model.Scenario{}).Where("id = ? AND tenant_id = ? AND status <> ?", id, p.TenantID, "archived").Updates(updates)
if result.Error != nil || result.RowsAffected == 0 {
response.Error(c, http.StatusNotFound, "NOT_FOUND", "场景不存在")
@@ -132,8 +147,26 @@ func (h *Handler) Archive(c *gin.Context) {
if !ok {
return
}
result := h.db.Model(&model.Scenario{}).Where("id = ? AND tenant_id = ?", id, p.TenantID).Update("status", "archived")
if result.Error != nil || result.RowsAffected == 0 {
if !access.CanEditScenario(h.db, p, id) {
response.Error(c, http.StatusNotFound, "NOT_FOUND", "场景不存在或不可归档")
return
}
var activeSOPs int64
if err := h.db.Model(&model.SOP{}).Where("scenario_id = ? AND tenant_id = ? AND status IN ?", id, p.TenantID, []string{"published", "reviewing"}).Count(&activeSOPs).Error; err != nil {
response.Error(c, http.StatusInternalServerError, "QUERY_FAILED", "检查场景状态失败")
return
}
if activeSOPs > 0 {
response.Error(c, http.StatusConflict, "SCENARIO_IN_USE", "请先下线已发布 SOP 或处理审核任务")
return
}
err := h.db.Transaction(func(tx *gorm.DB) error {
if err := tx.Model(&model.SOP{}).Where("scenario_id = ? AND tenant_id = ?", id, p.TenantID).Update("status", "archived").Error; err != nil {
return err
}
return tx.Model(&model.Scenario{}).Where("id = ? AND tenant_id = ?", id, p.TenantID).Update("status", "archived").Error
})
if err != nil {
response.Error(c, http.StatusNotFound, "NOT_FOUND", "场景不存在")
return
}
@@ -144,7 +177,7 @@ func (h *Handler) Archive(c *gin.Context) {
func (h *Handler) CreateField(c *gin.Context) {
p, _ := auth.PrincipalFromContext(c)
scenarioID, ok := idParam(c, "id")
if !ok || !h.scenarioExists(p.TenantID, scenarioID) {
if !ok || !access.CanEditScenario(h.db, p, scenarioID) {
if ok {
response.Error(c, http.StatusNotFound, "NOT_FOUND", "场景不存在")
}
@@ -174,6 +207,15 @@ func (h *Handler) UpdateField(c *gin.Context) {
if !ok {
return
}
var existing model.ScenarioField
if err := h.db.Where("id = ? AND tenant_id = ?", id, p.TenantID).First(&existing).Error; err != nil || !access.CanEditScenario(h.db, p, existing.ScenarioID) {
response.Error(c, http.StatusNotFound, "NOT_FOUND", "字段不存在或不可编辑")
return
}
if h.fieldReferencedByReleasedSOP(existing.ScenarioID, existing.FieldKey, p.TenantID) {
response.Error(c, http.StatusConflict, "FIELD_IN_USE", "字段已被发布版本引用,不能修改;请新增字段并创建 SOP 新版本")
return
}
var input fieldInput
if err := c.ShouldBindJSON(&input); err != nil {
response.Error(c, http.StatusBadRequest, "INVALID_ARGUMENT", "字段配置不正确")
@@ -201,6 +243,15 @@ func (h *Handler) DeleteField(c *gin.Context) {
if !ok {
return
}
var existing model.ScenarioField
if err := h.db.Where("id = ? AND tenant_id = ?", id, p.TenantID).First(&existing).Error; err != nil || !access.CanEditScenario(h.db, p, existing.ScenarioID) {
response.Error(c, http.StatusNotFound, "NOT_FOUND", "字段不存在或不可删除")
return
}
if h.fieldReferencedByReleasedSOP(existing.ScenarioID, existing.FieldKey, p.TenantID) {
response.Error(c, http.StatusConflict, "FIELD_IN_USE", "字段已被发布版本引用,不能删除")
return
}
result := h.db.Where("id = ? AND tenant_id = ?", id, p.TenantID).Delete(&model.ScenarioField{})
if result.Error != nil || result.RowsAffected == 0 {
response.Error(c, http.StatusNotFound, "NOT_FOUND", "字段不存在")
@@ -210,12 +261,6 @@ func (h *Handler) DeleteField(c *gin.Context) {
response.OK(c, gin.H{"id": id})
}
func (h *Handler) scenarioExists(tenantID, id uint64) bool {
var count int64
h.db.Model(&model.Scenario{}).Where("id = ? AND tenant_id = ? AND status <> ?", id, tenantID, "archived").Count(&count)
return count == 1
}
func normalizedJSON(value json.RawMessage, fallback string) datatypes.JSON {
if len(value) == 0 || !json.Valid(value) {
return datatypes.JSON([]byte(fallback))

View File

@@ -0,0 +1,63 @@
package scenario
import (
"encoding/json"
"git.iwork-ai.com/xdc/iqudo-top1/internal/model"
)
func (h *Handler) fieldReferencedByReleasedSOP(scenarioID uint64, fieldKey string, tenantID uint64) bool {
statuses := []string{"published", "offline", "superseded"}
var nodes []model.SOPNode
err := h.db.Table("sop_nodes n").Select("n.*").Joins("JOIN sop_versions sv ON sv.id = n.sop_version_id").Joins("JOIN sops s ON s.id = sv.sop_id").Where("s.scenario_id = ? AND n.tenant_id = ? AND sv.status IN ?", scenarioID, tenantID, statuses).Scan(&nodes).Error
if err != nil {
return true
}
for _, node := range nodes {
if jsonReferencesField(node.Config, fieldKey) {
return true
}
}
var edges []model.SOPEdge
err = h.db.Table("sop_edges e").Select("e.*").Joins("JOIN sop_versions sv ON sv.id = e.sop_version_id").Joins("JOIN sops s ON s.id = sv.sop_id").Where("s.scenario_id = ? AND e.tenant_id = ? AND sv.status IN ?", scenarioID, tenantID, statuses).Scan(&edges).Error
if err != nil {
return true
}
for _, edge := range edges {
if jsonReferencesField(edge.Condition, fieldKey) {
return true
}
}
return false
}
func jsonReferencesField(raw []byte, fieldKey string) bool {
var value interface{}
if err := json.Unmarshal(raw, &value); err != nil {
return true
}
return valueReferencesField(value, fieldKey)
}
func valueReferencesField(value interface{}, fieldKey string) bool {
switch typed := value.(type) {
case map[string]interface{}:
for key, child := range typed {
if key == "field_key" || key == "field" {
if child == fieldKey {
return true
}
}
if valueReferencesField(child, fieldKey) {
return true
}
}
case []interface{}:
for _, child := range typed {
if child == fieldKey || valueReferencesField(child, fieldKey) {
return true
}
}
}
return false
}

View File

@@ -0,0 +1,24 @@
package scenario
import "testing"
func TestJSONReferencesField(t *testing.T) {
tests := []struct {
name string
raw string
key string
want bool
}{
{name: "question", raw: `{"field_key":"pet_name"}`, key: "pet_name", want: true},
{name: "form", raw: `{"field_keys":["pet_name","pet_type"]}`, key: "pet_type", want: true},
{name: "nested condition", raw: `{"any":[{"field":"emergency","operator":"equals","value":true}]}`, key: "emergency", want: true},
{name: "not referenced", raw: `{"field_key":"pet_name"}`, key: "pet_type", want: false},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
if got := jsonReferencesField([]byte(test.raw), test.key); got != test.want {
t.Fatalf("jsonReferencesField() = %v, want %v", got, test.want)
}
})
}
}

View File

@@ -7,6 +7,7 @@ import (
"strconv"
"time"
"git.iwork-ai.com/xdc/iqudo-top1/internal/access"
"git.iwork-ai.com/xdc/iqudo-top1/internal/audit"
"git.iwork-ai.com/xdc/iqudo-top1/internal/auth"
"git.iwork-ai.com/xdc/iqudo-top1/internal/model"
@@ -54,16 +55,17 @@ type edgeInput struct {
func (h *Handler) List(c *gin.Context) {
p, _ := auth.PrincipalFromContext(c)
var items []model.SOP
query := h.db.Where("tenant_id = ? AND status <> ?", p.TenantID, "archived")
items := make([]model.SOP, 0)
query := h.db.Table("sops s").Select("s.*").Joins("JOIN scenarios sc ON sc.id = s.scenario_id")
query = access.ScopeScenarios(query, p, "sc").Where("s.tenant_id = ? AND s.status <> ?", p.TenantID, "archived")
scenarioID := c.Query("scenario_id")
if scenarioID == "" {
scenarioID = c.Param("id")
}
if scenarioID != "" {
query = query.Where("scenario_id = ?", scenarioID)
query = query.Where("s.scenario_id = ?", scenarioID)
}
if err := query.Order("updated_at DESC").Find(&items).Error; err != nil {
if err := query.Order("s.updated_at DESC").Scan(&items).Error; err != nil {
response.Error(c, http.StatusInternalServerError, "QUERY_FAILED", "查询 SOP 失败")
return
}
@@ -76,6 +78,10 @@ func (h *Handler) Create(c *gin.Context) {
if !ok {
return
}
if !access.CanEditScenario(h.db, p, scenarioID) {
response.Error(c, http.StatusNotFound, "NOT_FOUND", "场景不存在或不可编辑")
return
}
var scenario model.Scenario
if err := h.db.Where("id = ? AND tenant_id = ?", scenarioID, p.TenantID).First(&scenario).Error; err != nil {
response.Error(c, http.StatusNotFound, "NOT_FOUND", "场景不存在")
@@ -125,6 +131,10 @@ func (h *Handler) Get(c *gin.Context) {
if !ok {
return
}
if !access.CanViewSOP(h.db, p, id) {
response.Error(c, http.StatusNotFound, "NOT_FOUND", "SOP 不存在")
return
}
item, version, nodes, edges, err := h.loadLatest(id, p.TenantID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
@@ -143,6 +153,10 @@ func (h *Handler) SaveGraph(c *gin.Context) {
if !ok {
return
}
if !access.CanEditSOP(h.db, p, id) {
response.Error(c, http.StatusNotFound, "NOT_FOUND", "SOP 不存在或不可编辑")
return
}
var input graphInput
if err := c.ShouldBindJSON(&input); err != nil {
response.Error(c, http.StatusBadRequest, "INVALID_ARGUMENT", "流程配置不完整")
@@ -190,6 +204,10 @@ func (h *Handler) Validate(c *gin.Context) {
if !ok {
return
}
if !access.CanViewSOP(h.db, p, id) {
response.Error(c, http.StatusNotFound, "NOT_FOUND", "SOP 不存在")
return
}
item, version, nodes, edges, err := h.loadLatest(id, p.TenantID)
if err != nil {
response.Error(c, http.StatusNotFound, "NOT_FOUND", "SOP 不存在")
@@ -209,6 +227,10 @@ func (h *Handler) SubmitReview(c *gin.Context) {
if !ok {
return
}
if !access.CanEditSOP(h.db, p, id) {
response.Error(c, http.StatusNotFound, "NOT_FOUND", "SOP 不存在或不可提交")
return
}
item, version, nodes, edges, err := h.loadLatest(id, p.TenantID)
if err != nil || version.Status != "draft" {
response.Error(c, http.StatusConflict, "NO_DRAFT_VERSION", "没有可提交审核的草稿版本")
@@ -227,7 +249,14 @@ func (h *Handler) SubmitReview(c *gin.Context) {
if err := tx.Model(&version).Update("status", "reviewing").Error; err != nil {
return err
}
return tx.Model(&item).Update("status", "reviewing").Error
var published int64
if err := tx.Model(&model.SOPVersion{}).Where("sop_id = ? AND tenant_id = ? AND status = ?", id, p.TenantID, "published").Count(&published).Error; err != nil {
return err
}
if published == 0 {
return tx.Model(&item).Update("status", "reviewing").Error
}
return nil
})
if err != nil {
response.Error(c, http.StatusInternalServerError, "SUBMIT_REVIEW_FAILED", "提交审核失败")
@@ -243,11 +272,20 @@ func (h *Handler) Publish(c *gin.Context) {
if !ok {
return
}
if !access.CanViewSOP(h.db, p, id) {
response.Error(c, http.StatusNotFound, "NOT_FOUND", "SOP 不存在")
return
}
item, version, nodes, edges, err := h.loadLatest(id, p.TenantID)
if err != nil || (version.Status != "draft" && version.Status != "reviewing") {
canDirectPublish := auth.HasPermission(p, "*")
if err != nil || (version.Status != "reviewing" && !(version.Status == "draft" && canDirectPublish)) {
response.Error(c, http.StatusConflict, "NO_REVIEW_VERSION", "没有可发布的审核版本")
return
}
if !canDirectPublish && version.CreatedBy == p.UserID {
response.Error(c, http.StatusForbidden, "SELF_REVIEW_FORBIDDEN", "不能审核并发布自己创建的 SOP")
return
}
problems, validationErr := h.validateForPublish(item, version.StartNodeKey, nodes, edges, p.TenantID)
if validationErr != nil {
response.Error(c, http.StatusInternalServerError, "VALIDATION_FAILED", "校验流程失败")
@@ -284,6 +322,10 @@ func (h *Handler) Offline(c *gin.Context) {
if !ok {
return
}
if !access.CanViewSOP(h.db, p, id) {
response.Error(c, http.StatusNotFound, "NOT_FOUND", "已发布 SOP 不存在")
return
}
var item model.SOP
if err := h.db.Where("id = ? AND tenant_id = ? AND status = ?", id, p.TenantID, "published").First(&item).Error; err != nil {
response.Error(c, http.StatusNotFound, "NOT_FOUND", "已发布 SOP 不存在")
@@ -319,10 +361,14 @@ func (h *Handler) CreateVersion(c *gin.Context) {
if !ok {
return
}
if !access.CanEditSOP(h.db, p, id) {
response.Error(c, http.StatusNotFound, "NOT_FOUND", "SOP 不存在或不可编辑")
return
}
var existing int64
h.db.Model(&model.SOPVersion{}).Where("sop_id = ? AND tenant_id = ? AND status = ?", id, p.TenantID, "draft").Count(&existing)
h.db.Model(&model.SOPVersion{}).Where("sop_id = ? AND tenant_id = ? AND status IN ?", id, p.TenantID, []string{"draft", "reviewing"}).Count(&existing)
if existing > 0 {
response.Error(c, http.StatusConflict, "DRAFT_EXISTS", "已经存在草稿版本")
response.Error(c, http.StatusConflict, "DRAFT_EXISTS", "已经存在草稿或审核中的版本")
return
}
_, source, nodes, edges, err := h.loadLatest(id, p.TenantID)
@@ -369,8 +415,8 @@ func (h *Handler) loadLatest(sopID, tenantID uint64) (model.SOP, model.SOPVersio
if err := h.db.Where("sop_id = ? AND tenant_id = ?", sopID, tenantID).Order("version DESC").First(&version).Error; err != nil {
return item, version, nil, nil, err
}
var nodes []model.SOPNode
var edges []model.SOPEdge
nodes := make([]model.SOPNode, 0)
edges := make([]model.SOPEdge, 0)
if err := h.db.Where("sop_version_id = ? AND tenant_id = ?", version.ID, tenantID).Order("position_y, id").Find(&nodes).Error; err != nil {
return item, version, nil, nil, err
}
@@ -381,7 +427,7 @@ func (h *Handler) loadLatest(sopID, tenantID uint64) (model.SOP, model.SOPVersio
}
func (h *Handler) validateForPublish(item model.SOP, startNodeKey string, nodes []model.SOPNode, edges []model.SOPEdge, tenantID uint64) ([]string, error) {
var fields []model.ScenarioField
fields := make([]model.ScenarioField, 0)
if err := h.db.Where("scenario_id = ? AND tenant_id = ?", item.ScenarioID, tenantID).Find(&fields).Error; err != nil {
return nil, err
}

90
internal/sop/review.go Normal file
View File

@@ -0,0 +1,90 @@
package sop
import (
"net/http"
"time"
"git.iwork-ai.com/xdc/iqudo-top1/internal/access"
"git.iwork-ai.com/xdc/iqudo-top1/internal/audit"
"git.iwork-ai.com/xdc/iqudo-top1/internal/auth"
"git.iwork-ai.com/xdc/iqudo-top1/internal/model"
"git.iwork-ai.com/xdc/iqudo-top1/internal/response"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
type ReviewItem struct {
SOPID uint64 `json:"sop_id"`
SOPName string `json:"sop_name"`
Description string `json:"description"`
ScenarioID uint64 `json:"scenario_id"`
ScenarioName string `json:"scenario_name"`
VersionID uint64 `json:"version_id"`
Version int `json:"version"`
CreatorID uint64 `json:"creator_id"`
CreatorName string `json:"creator_name"`
SubmittedAt time.Time `json:"submitted_at"`
}
func (h *Handler) Reviews(c *gin.Context) {
principal, _ := auth.PrincipalFromContext(c)
items := make([]ReviewItem, 0)
query := h.db.Table("sop_versions sv").Select(
"s.id AS sop_id, s.name AS sop_name, s.description, sc.id AS scenario_id, sc.name AS scenario_name, " +
"sv.id AS version_id, sv.version, sv.created_by AS creator_id, u.display_name AS creator_name, sv.updated_at AS submitted_at",
).Joins("JOIN sops s ON s.id = sv.sop_id").Joins("JOIN scenarios sc ON sc.id = s.scenario_id").Joins("JOIN users u ON u.id = sv.created_by")
query = access.ScopeScenarios(query, principal, "sc")
if err := query.Where("sv.tenant_id = ? AND sv.status = ?", principal.TenantID, "reviewing").Order("sv.updated_at ASC").Scan(&items).Error; err != nil {
response.Error(c, http.StatusInternalServerError, "QUERY_FAILED", "查询审核任务失败")
return
}
response.OK(c, gin.H{"items": items, "total": len(items)})
}
func (h *Handler) Reject(c *gin.Context) {
principal, _ := auth.PrincipalFromContext(c)
sopID, ok := parseID(c, "id")
if !ok {
return
}
if !access.CanViewSOP(h.db, principal, sopID) {
response.Error(c, http.StatusNotFound, "NOT_FOUND", "SOP 不存在")
return
}
var input struct {
Reason string `json:"reason" binding:"required,max=1000"`
}
if err := c.ShouldBindJSON(&input); err != nil {
response.Error(c, http.StatusBadRequest, "INVALID_ARGUMENT", "请填写退回原因")
return
}
var version model.SOPVersion
if err := h.db.Where("sop_id = ? AND tenant_id = ? AND status = ?", sopID, principal.TenantID, "reviewing").Order("version DESC").First(&version).Error; err != nil {
response.Error(c, http.StatusConflict, "NO_REVIEW_VERSION", "没有待审核版本")
return
}
if !auth.HasPermission(principal, "*") && version.CreatedBy == principal.UserID {
response.Error(c, http.StatusForbidden, "SELF_REVIEW_FORBIDDEN", "不能审核自己创建的 SOP")
return
}
err := h.db.Transaction(func(tx *gorm.DB) error {
if err := tx.Model(&version).Updates(map[string]interface{}{"status": "draft", "reviewed_by": nil}).Error; err != nil {
return err
}
var published int64
if err := tx.Model(&model.SOPVersion{}).Where("sop_id = ? AND tenant_id = ? AND status = ?", sopID, principal.TenantID, "published").Count(&published).Error; err != nil {
return err
}
status := "draft"
if published > 0 {
status = "published"
}
return tx.Model(&model.SOP{}).Where("id = ? AND tenant_id = ?", sopID, principal.TenantID).Update("status", status).Error
})
if err != nil {
response.Error(c, http.StatusInternalServerError, "REJECT_FAILED", "退回审核失败")
return
}
_ = audit.Record(h.db, principal, "reject", "sop", sopID, gin.H{"version": version.Version, "reason": input.Reason})
response.OK(c, gin.H{"id": sopID, "version": version.Version, "status": "draft"})
}

View File

@@ -1 +1 @@
import{F as e,a as t}from"./client-CO11mUW5.js";var n={icon:{tag:`svg`,attrs:{viewBox:`64 64 896 896`,focusable:`false`},children:[{tag:`path`,attrs:{d:`M869 487.8L491.2 159.9c-2.9-2.5-6.6-3.9-10.5-3.9h-88.5c-7.4 0-10.8 9.2-5.2 14l350.2 304H152c-4.4 0-8 3.6-8 8v60c0 4.4 3.6 8 8 8h585.1L386.9 854c-5.6 4.9-2.2 14 5.2 14h91.5c1.9 0 3.8-.7 5.2-2L869 536.2a32.07 32.07 0 000-48.4z`}}]},name:`arrow-right`,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=`ArrowRightOutlined`,a.inheritAttrs=!1;var o={icon:{tag:`svg`,attrs:{viewBox:`64 64 896 896`,focusable:`false`},children:[{tag:`path`,attrs:{d:`M464 144H160c-8.8 0-16 7.2-16 16v304c0 8.8 7.2 16 16 16h304c8.8 0 16-7.2 16-16V160c0-8.8-7.2-16-16-16zm-52 268H212V212h200v200zm452-268H560c-8.8 0-16 7.2-16 16v304c0 8.8 7.2 16 16 16h304c8.8 0 16-7.2 16-16V160c0-8.8-7.2-16-16-16zm-52 268H612V212h200v200zM464 544H160c-8.8 0-16 7.2-16 16v304c0 8.8 7.2 16 16 16h304c8.8 0 16-7.2 16-16V560c0-8.8-7.2-16-16-16zm-52 268H212V612h200v200zm452-268H560c-8.8 0-16 7.2-16 16v304c0 8.8 7.2 16 16 16h304c8.8 0 16-7.2 16-16V560c0-8.8-7.2-16-16-16zm-52 268H612V612h200v200z`}}]},name:`appstore`,theme:`outlined`};function s(e){for(var t=1;t<arguments.length;t++){var n=arguments[t]==null?{}:Object(arguments[t]),r=Object.keys(n);typeof Object.getOwnPropertySymbols==`function`&&(r=r.concat(Object.getOwnPropertySymbols(n).filter(function(e){return Object.getOwnPropertyDescriptor(n,e).enumerable}))),r.forEach(function(t){c(e,t,n[t])})}return e}function c(e,t,n){return t in e?Object.defineProperty(e,t,{value:n,enumerable:!0,configurable:!0,writable:!0}):e[t]=n,e}var l=function(n,r){var i=s({},n,r.attrs);return e(t,s({},i,{icon:o}),null)};l.displayName=`AppstoreOutlined`,l.inheritAttrs=!1;export{a as n,l as t};
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:`M869 487.8L491.2 159.9c-2.9-2.5-6.6-3.9-10.5-3.9h-88.5c-7.4 0-10.8 9.2-5.2 14l350.2 304H152c-4.4 0-8 3.6-8 8v60c0 4.4 3.6 8 8 8h585.1L386.9 854c-5.6 4.9-2.2 14 5.2 14h91.5c1.9 0 3.8-.7 5.2-2L869 536.2a32.07 32.07 0 000-48.4z`}}]},name:`arrow-right`,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=`ArrowRightOutlined`,a.inheritAttrs=!1;var o={icon:{tag:`svg`,attrs:{viewBox:`64 64 896 896`,focusable:`false`},children:[{tag:`path`,attrs:{d:`M464 144H160c-8.8 0-16 7.2-16 16v304c0 8.8 7.2 16 16 16h304c8.8 0 16-7.2 16-16V160c0-8.8-7.2-16-16-16zm-52 268H212V212h200v200zm452-268H560c-8.8 0-16 7.2-16 16v304c0 8.8 7.2 16 16 16h304c8.8 0 16-7.2 16-16V160c0-8.8-7.2-16-16-16zm-52 268H612V212h200v200zM464 544H160c-8.8 0-16 7.2-16 16v304c0 8.8 7.2 16 16 16h304c8.8 0 16-7.2 16-16V560c0-8.8-7.2-16-16-16zm-52 268H212V612h200v200zm452-268H560c-8.8 0-16 7.2-16 16v304c0 8.8 7.2 16 16 16h304c8.8 0 16-7.2 16-16V560c0-8.8-7.2-16-16-16zm-52 268H612V612h200v200z`}}]},name:`appstore`,theme:`outlined`};function s(e){for(var t=1;t<arguments.length;t++){var n=arguments[t]==null?{}:Object(arguments[t]),r=Object.keys(n);typeof Object.getOwnPropertySymbols==`function`&&(r=r.concat(Object.getOwnPropertySymbols(n).filter(function(e){return Object.getOwnPropertyDescriptor(n,e).enumerable}))),r.forEach(function(t){c(e,t,n[t])})}return e}function c(e,t,n){return t in e?Object.defineProperty(e,t,{value:n,enumerable:!0,configurable:!0,writable:!0}):e[t]=n,e}var l=function(n,r){var i=s({},n,r.attrs);return e(t,s({},i,{icon:o}),null)};l.displayName=`AppstoreOutlined`,l.inheritAttrs=!1;export{a as n,l as t};

View File

@@ -1 +1 @@
import{F as e,a as t}from"./client-CO11mUW5.js";var n={icon:{tag:`svg`,attrs:{viewBox:`64 64 896 896`,focusable:`false`},children:[{tag:`path`,attrs:{d:`M872 474H286.9l350.2-304c5.6-4.9 2.2-14-5.2-14h-88.5c-3.9 0-7.6 1.4-10.5 3.9L155 487.8a31.96 31.96 0 000 48.3L535.1 866c1.5 1.3 3.3 2 5.2 2h91.5c7.4 0 10.8-9.2 5.2-14L286.9 550H872c4.4 0 8-3.6 8-8v-60c0-4.4-3.6-8-8-8z`}}]},name:`arrow-left`,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=`ArrowLeftOutlined`,a.inheritAttrs=!1;export{a as t};
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:`M872 474H286.9l350.2-304c5.6-4.9 2.2-14-5.2-14h-88.5c-3.9 0-7.6 1.4-10.5 3.9L155 487.8a31.96 31.96 0 000 48.3L535.1 866c1.5 1.3 3.3 2 5.2 2h91.5c7.4 0 10.8-9.2 5.2-14L286.9 550H872c4.4 0 8-3.6 8-8v-60c0-4.4-3.6-8-8-8z`}}]},name:`arrow-left`,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=`ArrowLeftOutlined`,a.inheritAttrs=!1;export{a as t};

View File

@@ -1 +1 @@
import{F as e,a as t}from"./client-CO11mUW5.js";var n={icon:{tag:`svg`,attrs:{viewBox:`64 64 896 896`,focusable:`false`},children:[{tag:`path`,attrs:{d:`M832 64H192c-17.7 0-32 14.3-32 32v832c0 17.7 14.3 32 32 32h640c17.7 0 32-14.3 32-32V96c0-17.7-14.3-32-32-32zm-260 72h96v209.9L621.5 312 572 347.4V136zm220 752H232V136h280v296.9c0 3.3 1 6.6 3 9.3a15.9 15.9 0 0022.3 3.7l83.8-59.9 81.4 59.4c2.7 2 6 3.1 9.4 3.1 8.8 0 16-7.2 16-16V136h64v752z`}}]},name:`book`,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=`BookOutlined`,a.inheritAttrs=!1;export{a as t};
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:`M832 64H192c-17.7 0-32 14.3-32 32v832c0 17.7 14.3 32 32 32h640c17.7 0 32-14.3 32-32V96c0-17.7-14.3-32-32-32zm-260 72h96v209.9L621.5 312 572 347.4V136zm220 752H232V136h280v296.9c0 3.3 1 6.6 3 9.3a15.9 15.9 0 0022.3 3.7l83.8-59.9 81.4 59.4c2.7 2 6 3.1 9.4 3.1 8.8 0 16-7.2 16-16V136h64v752z`}}]},name:`book`,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=`BookOutlined`,a.inheritAttrs=!1;export{a as t};

View File

@@ -1 +0,0 @@
import{Bt as e,F as t,I as n,O as r,P as i,Q as a,Tt as o,Y as s,i as c,j as l,lt as u,r as d,t as f,tt as p,vt as m}from"./client-CO11mUW5.js";import{t as h}from"./PlusOutlined-5Urx-Evx.js";import{n as g,t as _}from"./AppstoreOutlined-BOx6VBGQ.js";import{t as v}from"./PlayCircleOutlined-C2UGH7h6.js";import{h as y}from"./useApi-CROJJdhE-BlzMTLF9.js";var b={class:`page-shell`},x={class:`page-heading`},S={class:`page-actions`},C={class:`metric-strip surface`},w={class:`metric`},T={class:`metric-icon green`},E={class:`metric`},D={class:`metric-icon coral`},O={class:`metric`},k={class:`metric-icon amber`},A={class:`metric`},j={class:`completion`},M={class:`work-grid`},N={class:`surface action-panel`},P=d(n({__name:`DashboardView`,setup(n){let d=y(),P=m(!0),F=m({scenarios:0,published_sops:0,runs:0,completed_runs:0});return s(async()=>{try{F.value=await f.get(`/dashboard/summary`)}finally{P.value=!1}}),(n,s)=>{let f=p(`a-button`),m=p(`a-skeleton`);return a(),l(`div`,b,[r(`div`,x,[s[5]||=r(`div`,null,[r(`h1`,null,`今天从经验开始`),r(`p`,null,`查看知识沉淀和一线执行的最新状态。`)],-1),r(`div`,S,[t(f,{type:`primary`,onClick:s[0]||=e=>o(d).push(`/scenarios`)},{default:u(()=>[t(o(h)),s[4]||=i(`创建场景`,-1)]),_:1})])]),t(m,{loading:P.value,active:``},{default:u(()=>[r(`section`,C,[r(`div`,w,[r(`span`,T,[t(o(_))]),r(`div`,null,[r(`b`,null,e(F.value.scenarios),1),s[6]||=r(`small`,null,`可用场景`,-1)])]),r(`div`,E,[r(`span`,D,[t(o(c))]),r(`div`,null,[r(`b`,null,e(F.value.published_sops),1),s[7]||=r(`small`,null,`已发布 SOP`,-1)])]),r(`div`,O,[r(`span`,k,[t(o(v))]),r(`div`,null,[r(`b`,null,e(F.value.runs),1),s[8]||=r(`small`,null,`累计执行`,-1)])]),r(`div`,A,[r(`span`,j,e(F.value.runs?Math.round(F.value.completed_runs/F.value.runs*100):0)+`%`,1),r(`div`,null,[r(`b`,null,e(F.value.completed_runs),1),s[9]||=r(`small`,null,`完成执行`,-1)])])])]),_:1},8,[`loading`]),r(`section`,M,[r(`div`,N,[s[13]||=r(`div`,{class:`panel-kicker`},`常用入口`,-1),r(`button`,{onClick:s[1]||=e=>o(d).push(`/scenarios`)},[s[10]||=r(`span`,null,[i(`配置新的业务场景`),r(`small`,null,`定义字段、目标和触发条件`)],-1),t(o(g))]),r(`button`,{onClick:s[2]||=e=>o(d).push(`/execute`)},[s[11]||=r(`span`,null,[i(`开始执行已发布 SOP`),r(`small`,null,`根据客户回答逐步推进`)],-1),t(o(g))]),r(`button`,{onClick:s[3]||=e=>o(d).push(`/knowledge`)},[s[12]||=r(`span`,null,[i(`维护审核知识卡`),r(`small`,null,`统一口径与风险提示`)],-1),t(o(g))])]),s[14]||=r(`div`,{class:`surface doctrine-panel`},[r(`div`,{class:`panel-kicker`},`平台原则`),r(`blockquote`,null,`一句好话术不应只被收藏,它应该知道何时出现、下一步去哪,以及是否真的有效。`),r(`div`,{class:`flow-note`},[r(`span`,null,`创建`),r(`i`),r(`span`,null,`发布`),r(`i`),r(`span`,null,`执行`),r(`i`),r(`span`,null,`复盘`)])],-1)])])}}}),[[`__scopeId`,`data-v-911c7dcb`]]);export{P as default};

View File

@@ -0,0 +1 @@
.metric-strip[data-v-81958fcc]{grid-template-columns:repeat(4,1fr);display:grid;overflow:hidden}.metric[data-v-81958fcc]{border-right:1px solid var(--line);align-items:center;gap:16px;min-height:116px;padding:22px;display:flex}.metric[data-v-81958fcc]:last-child{border-right:0}.metric-icon[data-v-81958fcc],.completion[data-v-81958fcc]{border-radius:5px;place-items:center;width:42px;height:42px;font-size:19px;display:grid}.metric-icon.green[data-v-81958fcc]{color:#16775b;background:#e5f3ee}.metric-icon.coral[data-v-81958fcc]{color:#c4573f;background:#faece7}.metric-icon.amber[data-v-81958fcc]{color:#a86e1e;background:#f8efdf}.completion[data-v-81958fcc]{color:#202825;background:#e8ecea;font-size:12px;font-weight:800}.metric div[data-v-81958fcc]{flex-direction:column;display:flex}.metric b[data-v-81958fcc]{font-family:Noto Serif SC,serif;font-size:28px;line-height:1}.metric small[data-v-81958fcc]{color:var(--muted);margin-top:8px}.work-grid[data-v-81958fcc]{grid-template-columns:1.05fr .95fr;gap:18px;margin-top:18px;display:grid}.action-panel[data-v-81958fcc],.doctrine-panel[data-v-81958fcc]{padding:22px}.panel-kicker[data-v-81958fcc]{color:#7c8883;text-transform:uppercase;margin-bottom:14px;font-size:11px;font-weight:700}.action-panel button[data-v-81958fcc]{width:100%;color:var(--ink);text-align:left;border:0;border-bottom:1px solid var(--line);cursor:pointer;background:0 0;justify-content:space-between;align-items:center;padding:15px 0;display:flex}.action-panel button[data-v-81958fcc]:last-child{border-bottom:0}.action-panel button[data-v-81958fcc]:hover{color:var(--green)}.action-panel button span[data-v-81958fcc]{flex-direction:column;font-weight:650;display:flex}.action-panel button small[data-v-81958fcc]{color:var(--muted);margin-top:4px;font-weight:400}.doctrine-panel[data-v-81958fcc]{color:#fff;background:#202825;border-color:#202825}.doctrine-panel .panel-kicker[data-v-81958fcc]{color:#89a099}blockquote[data-v-81958fcc]{margin:30px 0 42px;font-family:Noto Serif SC,serif;font-size:22px;line-height:1.65}.flow-note[data-v-81958fcc]{color:#9cafaa;align-items:center;font-size:12px;display:flex}.flow-note i[data-v-81958fcc]{background:#4a5a54;flex:1;height:1px;margin:0 10px}@media (width<=900px){.metric-strip[data-v-81958fcc]{grid-template-columns:repeat(2,1fr)}.metric[data-v-81958fcc]:nth-child(2){border-right:0}.metric[data-v-81958fcc]:nth-child(-n+2){border-bottom:1px solid var(--line)}.work-grid[data-v-81958fcc]{grid-template-columns:1fr}}@media (width<=520px){.metric[data-v-81958fcc]{min-height:100px;padding:16px}.metric b[data-v-81958fcc]{font-size:24px}.metric-icon[data-v-81958fcc],.completion[data-v-81958fcc]{display:none}}

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,i as l,j as u,lt as d,r as f,t as p,tt as m,vt as h}from"./client-Bk7n1O1W.js";import{t as g}from"./auth-D6Tj4g1X.js";import{t as _}from"./PlusOutlined-CosM7zFe.js";import{n as v,t as y}from"./AppstoreOutlined-BD2QJkU0.js";import{t as b}from"./PlayCircleOutlined-DjeS67m_.js";import{h as x}from"./useApi-CROJJdhE-C8LJOf_8.js";var S={class:`page-shell`},C={class:`page-heading`},w={key:0,class:`page-actions`},T={class:`metric-strip surface`},E={class:`metric`},D={class:`metric-icon green`},O={class:`metric`},k={class:`metric-icon coral`},A={class:`metric`},j={class:`metric-icon amber`},M={class:`metric`},N={class:`completion`},P={class:`work-grid`},F={class:`surface action-panel`},I=f(r({__name:`DashboardView`,setup(r){let f=x(),I=g(),L=h(!0),R=h({scenarios:0,published_sops:0,runs:0,completed_runs:0});return c(async()=>{try{R.value=await p.get(`/dashboard/summary`)}finally{L.value=!1}}),(r,c)=>{let p=m(`a-button`),h=m(`a-skeleton`);return o(),u(`div`,S,[i(`div`,C,[c[7]||=i(`div`,null,[i(`h1`,null,`今天从经验开始`),i(`p`,null,`查看知识沉淀和一线执行的最新状态。`)],-1),s(I).can(`scenario.edit`)?(o(),u(`div`,w,[n(p,{type:`primary`,onClick:c[0]||=e=>s(f).push(`/scenarios`)},{default:d(()=>[n(s(_)),c[6]||=a(`创建场景`,-1)]),_:1})])):e(``,!0)]),n(h,{loading:L.value,active:``},{default:d(()=>[i(`section`,T,[i(`div`,E,[i(`span`,D,[n(s(y))]),i(`div`,null,[i(`b`,null,t(R.value.scenarios),1),c[8]||=i(`small`,null,`可用场景`,-1)])]),i(`div`,O,[i(`span`,k,[n(s(l))]),i(`div`,null,[i(`b`,null,t(R.value.published_sops),1),c[9]||=i(`small`,null,`已发布 SOP`,-1)])]),i(`div`,A,[i(`span`,j,[n(s(b))]),i(`div`,null,[i(`b`,null,t(R.value.runs),1),c[10]||=i(`small`,null,`累计执行`,-1)])]),i(`div`,M,[i(`span`,N,t(R.value.runs?Math.round(R.value.completed_runs/R.value.runs*100):0)+`%`,1),i(`div`,null,[i(`b`,null,t(R.value.completed_runs),1),c[11]||=i(`small`,null,`完成执行`,-1)])])])]),_:1},8,[`loading`]),i(`section`,P,[i(`div`,F,[c[17]||=i(`div`,{class:`panel-kicker`},`常用入口`,-1),s(I).can(`scenario.edit`)?(o(),u(`button`,{key:0,onClick:c[1]||=e=>s(f).push(`/scenarios`)},[c[12]||=i(`span`,null,[a(`配置新的业务场景`),i(`small`,null,`定义字段、目标和触发条件`)],-1),n(s(v))])):e(``,!0),s(I).can(`sop.review`)?(o(),u(`button`,{key:1,onClick:c[2]||=e=>s(f).push(`/reviews`)},[c[13]||=i(`span`,null,[a(`处理待审核 SOP`),i(`small`,null,`核对流程、口径和风险出口`)],-1),n(s(v))])):e(``,!0),s(I).can(`sop.execute`)?(o(),u(`button`,{key:2,onClick:c[3]||=e=>s(f).push(`/execute`)},[c[14]||=i(`span`,null,[a(`开始执行已发布 SOP`),i(`small`,null,`根据客户回答逐步推进`)],-1),n(s(v))])):e(``,!0),s(I).can(`knowledge.edit`)?(o(),u(`button`,{key:3,onClick:c[4]||=e=>s(f).push(`/knowledge`)},[c[15]||=i(`span`,null,[a(`维护审核知识卡`),i(`small`,null,`统一口径与风险提示`)],-1),n(s(v))])):e(``,!0),s(I).can(`runs.view_all`)||s(I).can(`runs.view_own`)?(o(),u(`button`,{key:4,onClick:c[5]||=e=>s(f).push(`/runs`)},[c[16]||=i(`span`,null,[a(`复盘执行记录`),i(`small`,null,`查看节点轨迹、回答与反馈`)],-1),n(s(v))])):e(``,!0)]),c[18]||=i(`div`,{class:`surface doctrine-panel`},[i(`div`,{class:`panel-kicker`},`平台原则`),i(`blockquote`,null,`一句好话术不应只被收藏,它应该知道何时出现、下一步去哪,以及是否真的有效。`),i(`div`,{class:`flow-note`},[i(`span`,null,`创建`),i(`i`),i(`span`,null,`发布`),i(`i`),i(`span`,null,`执行`),i(`i`),i(`span`,null,`复盘`)])],-1)])])}}}),[[`__scopeId`,`data-v-81958fcc`]]);export{I as default};

View File

@@ -1 +0,0 @@
.metric-strip[data-v-911c7dcb]{grid-template-columns:repeat(4,1fr);display:grid;overflow:hidden}.metric[data-v-911c7dcb]{border-right:1px solid var(--line);align-items:center;gap:16px;min-height:116px;padding:22px;display:flex}.metric[data-v-911c7dcb]:last-child{border-right:0}.metric-icon[data-v-911c7dcb],.completion[data-v-911c7dcb]{border-radius:5px;place-items:center;width:42px;height:42px;font-size:19px;display:grid}.metric-icon.green[data-v-911c7dcb]{color:#16775b;background:#e5f3ee}.metric-icon.coral[data-v-911c7dcb]{color:#c4573f;background:#faece7}.metric-icon.amber[data-v-911c7dcb]{color:#a86e1e;background:#f8efdf}.completion[data-v-911c7dcb]{color:#202825;background:#e8ecea;font-size:12px;font-weight:800}.metric div[data-v-911c7dcb]{flex-direction:column;display:flex}.metric b[data-v-911c7dcb]{font-family:Noto Serif SC,serif;font-size:28px;line-height:1}.metric small[data-v-911c7dcb]{color:var(--muted);margin-top:8px}.work-grid[data-v-911c7dcb]{grid-template-columns:1.05fr .95fr;gap:18px;margin-top:18px;display:grid}.action-panel[data-v-911c7dcb],.doctrine-panel[data-v-911c7dcb]{padding:22px}.panel-kicker[data-v-911c7dcb]{color:#7c8883;text-transform:uppercase;margin-bottom:14px;font-size:11px;font-weight:700}.action-panel button[data-v-911c7dcb]{width:100%;color:var(--ink);text-align:left;border:0;border-bottom:1px solid var(--line);cursor:pointer;background:0 0;justify-content:space-between;align-items:center;padding:15px 0;display:flex}.action-panel button[data-v-911c7dcb]:last-child{border-bottom:0}.action-panel button[data-v-911c7dcb]:hover{color:var(--green)}.action-panel button span[data-v-911c7dcb]{flex-direction:column;font-weight:650;display:flex}.action-panel button small[data-v-911c7dcb]{color:var(--muted);margin-top:4px;font-weight:400}.doctrine-panel[data-v-911c7dcb]{color:#fff;background:#202825;border-color:#202825}.doctrine-panel .panel-kicker[data-v-911c7dcb]{color:#89a099}blockquote[data-v-911c7dcb]{margin:30px 0 42px;font-family:Noto Serif SC,serif;font-size:22px;line-height:1.65}.flow-note[data-v-911c7dcb]{color:#9cafaa;align-items:center;font-size:12px;display:flex}.flow-note i[data-v-911c7dcb]{background:#4a5a54;flex:1;height:1px;margin:0 10px}@media (width<=900px){.metric-strip[data-v-911c7dcb]{grid-template-columns:repeat(2,1fr)}.metric[data-v-911c7dcb]:nth-child(2){border-right:0}.metric[data-v-911c7dcb]:nth-child(-n+2){border-bottom:1px solid var(--line)}.work-grid[data-v-911c7dcb]{grid-template-columns:1fr}}@media (width<=520px){.metric[data-v-911c7dcb]{min-height:100px;padding:16px}.metric b[data-v-911c7dcb]{font-size:24px}.metric-icon[data-v-911c7dcb],.completion[data-v-911c7dcb]{display:none}}

File diff suppressed because one or more lines are too long

View File

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

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

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:`M912 190h-69.9c-9.8 0-19.1 4.5-25.1 12.2L404.7 724.5 207 474a32 32 0 00-25.1-12.2H112c-6.7 0-10.4 7.7-6.3 12.9l273.9 347c12.8 16.2 37.4 16.2 50.3 0l488.4-618.9c4.1-5.1.4-12.8-6.3-12.8z`}}]},name:`check`,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=`CheckOutlined`,a.inheritAttrs=!1;var o={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-372s166.6-372 372-372 372 166.6 372 372-166.6 372-372 372z`}},{tag:`path`,attrs:{d:`M686.7 638.6L544.1 535.5V288c0-4.4-3.6-8-8-8H488c-4.4 0-8 3.6-8 8v275.4c0 2.6 1.2 5 3.3 6.5l165.4 120.6c3.6 2.6 8.6 1.8 11.2-1.7l28.6-39c2.6-3.7 1.8-8.7-1.8-11.2z`}}]},name:`clock-circle`,theme:`outlined`};function s(e){for(var t=1;t<arguments.length;t++){var n=arguments[t]==null?{}:Object(arguments[t]),r=Object.keys(n);typeof Object.getOwnPropertySymbols==`function`&&(r=r.concat(Object.getOwnPropertySymbols(n).filter(function(e){return Object.getOwnPropertyDescriptor(n,e).enumerable}))),r.forEach(function(t){c(e,t,n[t])})}return e}function c(e,t,n){return t in e?Object.defineProperty(e,t,{value:n,enumerable:!0,configurable:!0,writable:!0}):e[t]=n,e}var l=function(n,r){var i=s({},n,r.attrs);return e(t,s({},i,{icon:o}),null)};l.displayName=`ClockCircleOutlined`,l.inheritAttrs=!1;var u={icon:{tag:`svg`,attrs:{viewBox:`64 64 896 896`,focusable:`false`},children:[{tag:`path`,attrs:{d:`M942.2 486.2C847.4 286.5 704.1 186 512 186c-192.2 0-335.4 100.5-430.2 300.3a60.3 60.3 0 000 51.5C176.6 737.5 319.9 838 512 838c192.2 0 335.4-100.5 430.2-300.3 7.7-16.2 7.7-35 0-51.5zM512 766c-161.3 0-279.4-81.8-362.7-254C232.6 339.8 350.7 258 512 258c161.3 0 279.4 81.8 362.7 254C791.5 684.2 673.4 766 512 766zm-4-430c-97.2 0-176 78.8-176 176s78.8 176 176 176 176-78.8 176-176-78.8-176-176-176zm0 288c-61.9 0-112-50.1-112-112s50.1-112 112-112 112 50.1 112 112-50.1 112-112 112z`}}]},name:`eye`,theme:`outlined`};function d(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){f(e,t,n[t])})}return e}function f(e,t,n){return t in e?Object.defineProperty(e,t,{value:n,enumerable:!0,configurable:!0,writable:!0}):e[t]=n,e}var p=function(n,r){var i=d({},n,r.attrs);return e(t,d({},i,{icon:u}),null)};p.displayName=`EyeOutlined`,p.inheritAttrs=!1;export{l as n,a as r,p as t};

View File

@@ -0,0 +1 @@
.knowledge-grid[data-v-68fdbe9b]{grid-template-columns:repeat(3,minmax(0,1fr));gap:14px;display:grid}.knowledge-card[data-v-68fdbe9b]{flex-direction:column;min-height:250px;padding:20px;display:flex}.knowledge-card header[data-v-68fdbe9b]{justify-content:space-between;display:flex}.knowledge-card header>span[data-v-68fdbe9b]{width:36px;height:36px;color:var(--green);background:#e8f3ef;border-radius:4px;place-items:center;display:grid}.knowledge-card h2[data-v-68fdbe9b]{margin:20px 0 10px;font-family:Noto Serif SC,serif;font-size:19px}.knowledge-card>p[data-v-68fdbe9b]{color:#4f5c56;margin:0;line-height:1.7}.risk-note[data-v-68fdbe9b]{color:#8b5b16;background:#fbf3e5;border-left:2px solid #c68a37;margin-top:14px;padding:9px 11px;font-size:12px}.knowledge-card footer[data-v-68fdbe9b]{color:var(--muted);justify-content:space-between;align-items:center;margin-top:auto;padding-top:18px;font-size:12px;display:flex}@media (width<=1050px){.knowledge-grid[data-v-68fdbe9b]{grid-template-columns:repeat(2,1fr)}}@media (width<=650px){.knowledge-grid[data-v-68fdbe9b]{grid-template-columns:1fr}}

View File

@@ -0,0 +1 @@
import{A as e,Bt as t,D as n,F as r,I as i,O as a,P as o,Q as s,S as c,Tt as l,Y as u,_t as d,et as f,j as p,k as m,lt as h,n as g,r as _,t as v,tt as y,vt as b}from"./client-Bk7n1O1W.js";import{a as x}from"./config-provider-CxhqvDI_.js";import{t as S}from"./auth-D6Tj4g1X.js";import{n as C,t as w}from"./DeleteOutlined-CRNedxr9.js";import{t as T}from"./PlusOutlined-CosM7zFe.js";import{t as E}from"./BookOutlined-Canuw-q-.js";var D={class:`page-shell`},O={class:`page-heading`},k={key:0,class:`knowledge-grid`},A={key:0,class:`risk-note`},j=_(i({__name:`KnowledgeView`,setup(i){let _=b(!1),j=b(!1),M=b([]),N=b([]),P=S(),F=d({scenario_id:void 0,title:``,content:``,forbidden:``,risk_note:``}),I=n(()=>N.value.map(e=>({value:e.id,label:e.name})));async function L(){_.value=!0;try{let[e,t]=await Promise.all([v.get(`/knowledge-cards`),v.get(`/scenarios`)]);M.value=e.items,N.value=t.items}catch(e){x.error(g(e))}finally{_.value=!1}}async function R(){try{await v.post(`/knowledge-cards`,{scenario_id:F.scenario_id,title:F.title,content:{standard_copy:F.content,forbidden_copy:F.forbidden,risk_note:F.risk_note}}),x.success(`知识卡已发布`),j.value=!1,Object.assign(F,{scenario_id:void 0,title:``,content:``,forbidden:``,risk_note:``}),await L()}catch(e){x.error(g(e))}}function z(e){C.confirm({title:`归档“${e.title}”?`,okType:`danger`,async onOk(){await v.delete(`/knowledge-cards/${e.id}`),await L()}})}function B(e,t){return e.content?.[t]||``}return u(L),(n,i)=>{let u=y(`a-button`),d=y(`a-tag`),g=y(`a-empty`),v=y(`a-spin`),b=y(`a-select`),x=y(`a-form-item`),S=y(`a-input`),C=y(`a-textarea`),L=y(`a-form`),V=y(`a-drawer`);return s(),p(`div`,D,[a(`div`,O,[i[8]||=a(`div`,null,[a(`h1`,null,`知识卡`),a(`p`,null,`沉淀经过审核的标准表达、禁用语和风险提醒。`)],-1),l(P).can(`knowledge.edit`)?(s(),m(u,{key:0,type:`primary`,onClick:i[0]||=e=>j.value=!0},{default:h(()=>[r(l(T)),i[7]||=o(`新建知识卡`,-1)]),_:1})):e(``,!0)]),r(v,{spinning:_.value},{default:h(()=>[M.value.length?(s(),p(`div`,k,[(s(!0),p(c,null,f(M.value,n=>(s(),p(`article`,{key:n.id,class:`knowledge-card surface`},[a(`header`,null,[a(`span`,null,[r(l(E))]),r(d,{color:`green`},{default:h(()=>[...i[9]||=[o(`已发布`,-1)]]),_:1})]),a(`h2`,null,t(n.title),1),a(`p`,null,t(B(n,`standard_copy`)||`暂无标准话术`),1),B(n,`risk_note`)?(s(),p(`div`,A,t(B(n,`risk_note`)),1)):e(``,!0),a(`footer`,null,[a(`span`,null,t(N.value.find(e=>e.id===n.scenario_id)?.name||`未分类场景`),1),l(P).can(`knowledge.edit`)?(s(),m(u,{key:0,type:`text`,danger:``,"aria-label":`归档知识卡`,onClick:e=>z(n)},{default:h(()=>[r(l(w))]),_:1},8,[`onClick`])):e(``,!0)])]))),128))])):(s(),m(g,{key:1,description:`还没有知识卡`}))]),_:1},8,[`spinning`]),l(P).can(`knowledge.edit`)?(s(),m(V,{key:0,open:j.value,"onUpdate:open":i[6]||=e=>j.value=e,title:`新建知识卡`,width:`520`},{default:h(()=>[r(L,{layout:`vertical`,model:F,onFinish:R},{default:h(()=>[r(x,{label:`所属场景`,name:`scenario_id`,rules:[{required:!0,message:`请选择场景`}]},{default:h(()=>[r(b,{value:F.scenario_id,"onUpdate:value":i[1]||=e=>F.scenario_id=e,options:I.value},null,8,[`value`,`options`])]),_:1}),r(x,{label:`标题`,name:`title`,rules:[{required:!0,message:`请输入标题`}]},{default:h(()=>[r(S,{value:F.title,"onUpdate:value":i[2]||=e=>F.title=e},null,8,[`value`])]),_:1}),r(x,{label:`标准话术`,name:`content`,rules:[{required:!0,message:`请输入标准话术`}]},{default:h(()=>[r(C,{value:F.content,"onUpdate:value":i[3]||=e=>F.content=e,rows:5},null,8,[`value`])]),_:1}),r(x,{label:`禁用表达`},{default:h(()=>[r(C,{value:F.forbidden,"onUpdate:value":i[4]||=e=>F.forbidden=e,rows:3},null,8,[`value`])]),_:1}),r(x,{label:`风险提醒`},{default:h(()=>[r(C,{value:F.risk_note,"onUpdate:value":i[5]||=e=>F.risk_note=e,rows:3},null,8,[`value`])]),_:1}),r(u,{type:`primary`,"html-type":`submit`,block:``},{default:h(()=>[...i[10]||=[o(`发布知识卡`,-1)]]),_:1})]),_:1},8,[`model`])]),_:1},8,[`open`])):e(``,!0)])}}}),[[`__scopeId`,`data-v-68fdbe9b`]]);export{j as default};

View File

@@ -1 +0,0 @@
import{A as e,Bt as t,D as n,F as r,I as i,O as a,P as o,Q as s,S as c,Tt as l,Y as u,_t as d,et as f,j as p,k as m,lt as h,n as g,r as _,t as v,tt as y,vt as b}from"./client-CO11mUW5.js";import{a as x}from"./config-provider-kwhtQ-D4.js";import{n as S,t as C}from"./DeleteOutlined-Dsl9pMnk.js";import{t as w}from"./PlusOutlined-5Urx-Evx.js";import{t as T}from"./BookOutlined-CNUY9qCc.js";var E={class:`page-shell`},D={class:`page-heading`},O={key:0,class:`knowledge-grid`},k={key:0,class:`risk-note`},A=_(i({__name:`KnowledgeView`,setup(i){let _=b(!1),A=b(!1),j=b([]),M=b([]),N=d({scenario_id:void 0,title:``,content:``,forbidden:``,risk_note:``}),P=n(()=>M.value.map(e=>({value:e.id,label:e.name})));async function F(){_.value=!0;try{let[e,t]=await Promise.all([v.get(`/knowledge-cards`),v.get(`/scenarios`)]);j.value=e.items,M.value=t.items}catch(e){x.error(g(e))}finally{_.value=!1}}async function I(){try{await v.post(`/knowledge-cards`,{scenario_id:N.scenario_id,title:N.title,content:{standard_copy:N.content,forbidden_copy:N.forbidden,risk_note:N.risk_note}}),x.success(`知识卡已发布`),A.value=!1,Object.assign(N,{scenario_id:void 0,title:``,content:``,forbidden:``,risk_note:``}),await F()}catch(e){x.error(g(e))}}function L(e){S.confirm({title:`归档“${e.title}”?`,okType:`danger`,async onOk(){await v.delete(`/knowledge-cards/${e.id}`),await F()}})}function R(e,t){return e.content?.[t]||``}return u(F),(n,i)=>{let u=y(`a-button`),d=y(`a-tag`),g=y(`a-empty`),v=y(`a-spin`),b=y(`a-select`),x=y(`a-form-item`),S=y(`a-input`),F=y(`a-textarea`),z=y(`a-form`),B=y(`a-drawer`);return s(),p(`div`,E,[a(`div`,D,[i[8]||=a(`div`,null,[a(`h1`,null,`知识卡`),a(`p`,null,`沉淀经过审核的标准表达、禁用语和风险提醒。`)],-1),r(u,{type:`primary`,onClick:i[0]||=e=>A.value=!0},{default:h(()=>[r(l(w)),i[7]||=o(`新建知识卡`,-1)]),_:1})]),r(v,{spinning:_.value},{default:h(()=>[j.value.length?(s(),p(`div`,O,[(s(!0),p(c,null,f(j.value,n=>(s(),p(`article`,{key:n.id,class:`knowledge-card surface`},[a(`header`,null,[a(`span`,null,[r(l(T))]),r(d,{color:`green`},{default:h(()=>[...i[9]||=[o(`已发布`,-1)]]),_:1})]),a(`h2`,null,t(n.title),1),a(`p`,null,t(R(n,`standard_copy`)||`暂无标准话术`),1),R(n,`risk_note`)?(s(),p(`div`,k,t(R(n,`risk_note`)),1)):e(``,!0),a(`footer`,null,[a(`span`,null,t(M.value.find(e=>e.id===n.scenario_id)?.name||`未分类场景`),1),r(u,{type:`text`,danger:``,"aria-label":`归档知识卡`,onClick:e=>L(n)},{default:h(()=>[r(l(C))]),_:1},8,[`onClick`])])]))),128))])):(s(),m(g,{key:1,description:`还没有知识卡`}))]),_:1},8,[`spinning`]),r(B,{open:A.value,"onUpdate:open":i[6]||=e=>A.value=e,title:`新建知识卡`,width:`520`},{default:h(()=>[r(z,{layout:`vertical`,model:N,onFinish:I},{default:h(()=>[r(x,{label:`所属场景`,name:`scenario_id`,rules:[{required:!0,message:`请选择场景`}]},{default:h(()=>[r(b,{value:N.scenario_id,"onUpdate:value":i[1]||=e=>N.scenario_id=e,options:P.value},null,8,[`value`,`options`])]),_:1}),r(x,{label:`标题`,name:`title`,rules:[{required:!0,message:`请输入标题`}]},{default:h(()=>[r(S,{value:N.title,"onUpdate:value":i[2]||=e=>N.title=e},null,8,[`value`])]),_:1}),r(x,{label:`标准话术`,name:`content`,rules:[{required:!0,message:`请输入标准话术`}]},{default:h(()=>[r(F,{value:N.content,"onUpdate:value":i[3]||=e=>N.content=e,rows:5},null,8,[`value`])]),_:1}),r(x,{label:`禁用表达`},{default:h(()=>[r(F,{value:N.forbidden,"onUpdate:value":i[4]||=e=>N.forbidden=e,rows:3},null,8,[`value`])]),_:1}),r(x,{label:`风险提醒`},{default:h(()=>[r(F,{value:N.risk_note,"onUpdate:value":i[5]||=e=>N.risk_note=e,rows:3},null,8,[`value`])]),_:1}),r(u,{type:`primary`,"html-type":`submit`,block:``},{default:h(()=>[...i[10]||=[o(`发布知识卡`,-1)]]),_:1})]),_:1},8,[`model`])]),_:1},8,[`open`])])}}}),[[`__scopeId`,`data-v-fc5e74e6`]]);export{A as default};

View File

@@ -1 +0,0 @@
.knowledge-grid[data-v-fc5e74e6]{grid-template-columns:repeat(3,minmax(0,1fr));gap:14px;display:grid}.knowledge-card[data-v-fc5e74e6]{flex-direction:column;min-height:250px;padding:20px;display:flex}.knowledge-card header[data-v-fc5e74e6]{justify-content:space-between;display:flex}.knowledge-card header>span[data-v-fc5e74e6]{width:36px;height:36px;color:var(--green);background:#e8f3ef;border-radius:4px;place-items:center;display:grid}.knowledge-card h2[data-v-fc5e74e6]{margin:20px 0 10px;font-family:Noto Serif SC,serif;font-size:19px}.knowledge-card>p[data-v-fc5e74e6]{color:#4f5c56;margin:0;line-height:1.7}.risk-note[data-v-fc5e74e6]{color:#8b5b16;background:#fbf3e5;border-left:2px solid #c68a37;margin-top:14px;padding:9px 11px;font-size:12px}.knowledge-card footer[data-v-fc5e74e6]{color:var(--muted);justify-content:space-between;align-items:center;margin-top:auto;padding-top:18px;font-size:12px;display:flex}@media (width<=1050px){.knowledge-grid[data-v-fc5e74e6]{grid-template-columns:repeat(2,1fr)}}@media (width<=650px){.knowledge-grid[data-v-fc5e74e6]{grid-template-columns:1fr}}

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
.members-page[data-v-00e79f6a]{max-width:1180px}.member-summary[data-v-00e79f6a]{color:#fff;background:#202825;border-radius:6px;grid-template-columns:38px 90px 1fr;align-items:center;gap:14px;min-height:86px;margin-bottom:16px;padding:16px 20px;display:grid}.member-summary>span[data-v-00e79f6a]{color:#6bc5a7;font-size:25px}.member-summary>div[data-v-00e79f6a]{flex-direction:column;display:flex}.member-summary b[data-v-00e79f6a]{font-family:Noto Serif SC,serif;font-size:24px}.member-summary div span[data-v-00e79f6a]{color:#a9b8b2;font-size:11px}.member-summary p[data-v-00e79f6a]{color:#b6c3be;margin:0;line-height:1.7}.member-cell[data-v-00e79f6a]{align-items:center;gap:11px;display:flex}.member-cell>span[data-v-00e79f6a]{color:#17664f;background:#e5f2ed;border-radius:4px;place-items:center;width:34px;height:34px;font-weight:700;display:grid}.member-cell>div[data-v-00e79f6a]{flex-direction:column;display:flex}.member-cell small[data-v-00e79f6a]{color:var(--muted);margin-top:3px}.form-grid[data-v-00e79f6a]{grid-template-columns:1fr 1fr;gap:12px;display:grid}.editing-name[data-v-00e79f6a]{border-left:3px solid var(--green);background:#f4f7f5;flex-direction:column;margin-bottom:18px;padding:12px 14px;display:flex}.editing-name span[data-v-00e79f6a]{color:var(--muted);margin-top:4px;font-size:12px}@media (width<=650px){.member-summary[data-v-00e79f6a]{grid-template-columns:34px 1fr}.member-summary p[data-v-00e79f6a]{grid-column:1/3}.form-grid[data-v-00e79f6a]{grid-template-columns:1fr;gap:0}}

File diff suppressed because one or more lines are too long

View File

@@ -1 +1 @@
import{F as e,a as t}from"./client-CO11mUW5.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-372s166.6-372 372-372 372 166.6 372 372-166.6 372-372 372z`}},{tag:`path`,attrs:{d:`M719.4 499.1l-296.1-215A15.9 15.9 0 00398 297v430c0 13.1 14.8 20.5 25.3 12.9l296.1-215a15.9 15.9 0 000-25.8zm-257.6 134V390.9L628.5 512 461.8 633.1z`}}]},name:`play-circle`,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=`PlayCircleOutlined`,a.inheritAttrs=!1;export{a as t};
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-372s166.6-372 372-372 372 166.6 372 372-166.6 372-372 372z`}},{tag:`path`,attrs:{d:`M719.4 499.1l-296.1-215A15.9 15.9 0 00398 297v430c0 13.1 14.8 20.5 25.3 12.9l296.1-215a15.9 15.9 0 000-25.8zm-257.6 134V390.9L628.5 512 461.8 633.1z`}}]},name:`play-circle`,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=`PlayCircleOutlined`,a.inheritAttrs=!1;export{a as t};

View File

@@ -1 +1 @@
import{F as e,a as t}from"./client-CO11mUW5.js";var n={icon:{tag:`svg`,attrs:{viewBox:`64 64 896 896`,focusable:`false`},children:[{tag:`path`,attrs:{d:`M482 152h60q8 0 8 8v704q0 8-8 8h-60q-8 0-8-8V160q0-8 8-8z`}},{tag:`path`,attrs:{d:`M192 474h672q8 0 8 8v60q0 8-8 8H160q-8 0-8-8v-60q0-8 8-8z`}}]},name:`plus`,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=`PlusOutlined`,a.inheritAttrs=!1;export{a as t};
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:`M482 152h60q8 0 8 8v704q0 8-8 8h-60q-8 0-8-8V160q0-8 8-8z`}},{tag:`path`,attrs:{d:`M192 474h672q8 0 8 8v60q0 8-8 8H160q-8 0-8-8v-60q0-8 8-8z`}}]},name:`plus`,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=`PlusOutlined`,a.inheritAttrs=!1;export{a as t};

View File

@@ -0,0 +1 @@
import{F as e,a as t}from"./client-Bk7n1O1W.js";var n={icon:{tag:`svg`,attrs:{viewBox:`64 64 896 896`,focusable:`false`},children:[{tag:`path`,attrs:{d:`M765.7 486.8L314.9 134.7A7.97 7.97 0 00302 141v77.3c0 4.9 2.3 9.6 6.1 12.6l360 281.1-360 281.1c-3.9 3-6.1 7.7-6.1 12.6V883c0 6.7 7.7 10.4 12.9 6.3l450.8-352.1a31.96 31.96 0 000-50.4z`}}]},name:`right`,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=`RightOutlined`,a.inheritAttrs=!1;var o={icon:{tag:`svg`,attrs:{viewBox:`64 64 896 896`,focusable:`false`},children:[{tag:`path`,attrs:{d:`M909.1 209.3l-56.4 44.1C775.8 155.1 656.2 92 521.9 92 290 92 102.3 279.5 102 511.5 101.7 743.7 289.8 932 521.9 932c181.3 0 335.8-115 394.6-276.1 1.5-4.2-.7-8.9-4.9-10.3l-56.7-19.5a8 8 0 00-10.1 4.8c-1.8 5-3.8 10-5.9 14.9-17.3 41-42.1 77.8-73.7 109.4A344.77 344.77 0 01655.9 829c-42.3 17.9-87.4 27-133.8 27-46.5 0-91.5-9.1-133.8-27A341.5 341.5 0 01279 755.2a342.16 342.16 0 01-73.7-109.4c-17.9-42.4-27-87.4-27-133.9s9.1-91.5 27-133.9c17.3-41 42.1-77.8 73.7-109.4 31.6-31.6 68.4-56.4 109.3-73.8 42.3-17.9 87.4-27 133.8-27 46.5 0 91.5 9.1 133.8 27a341.5 341.5 0 01109.3 73.8c9.9 9.9 19.2 20.4 27.8 31.4l-60.2 47a8 8 0 003 14.1l175.6 43c5 1.2 9.9-2.6 9.9-7.7l.8-180.9c-.1-6.6-7.8-10.3-13-6.2z`}}]},name:`reload`,theme:`outlined`};function s(e){for(var t=1;t<arguments.length;t++){var n=arguments[t]==null?{}:Object(arguments[t]),r=Object.keys(n);typeof Object.getOwnPropertySymbols==`function`&&(r=r.concat(Object.getOwnPropertySymbols(n).filter(function(e){return Object.getOwnPropertyDescriptor(n,e).enumerable}))),r.forEach(function(t){c(e,t,n[t])})}return e}function c(e,t,n){return t in e?Object.defineProperty(e,t,{value:n,enumerable:!0,configurable:!0,writable:!0}):e[t]=n,e}var l=function(n,r){var i=s({},n,r.attrs);return e(t,s({},i,{icon:o}),null)};l.displayName=`ReloadOutlined`,l.inheritAttrs=!1;export{a as n,l as t};

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,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 @@
.queue-count[data-v-88d0e1a8]{color:#3f4c46;border:1px solid var(--line);background:#fff;border-radius:6px;grid-template-columns:24px 32px auto;align-items:center;gap:8px;height:54px;padding:0 16px;display:grid}.queue-count>span[data-v-88d0e1a8]:first-child{color:var(--amber)}.queue-count b[data-v-88d0e1a8]{font-family:Noto Serif SC,serif;font-size:24px}.queue-count span[data-v-88d0e1a8]:last-child{color:var(--muted);font-size:12px}.review-table[data-v-88d0e1a8]{overflow:hidden}.review-guideline[data-v-88d0e1a8]{color:#637069;border-bottom:1px solid var(--line);background:#f7f9f8;align-items:center;gap:12px;min-height:48px;padding:0 16px;font-size:11px;display:flex}.review-guideline>span[data-v-88d0e1a8]{color:#28332e;font-weight:750}.review-guideline i[data-v-88d0e1a8]{background:#a7b2ad;border-radius:50%;width:3px;height:3px}.sop-cell[data-v-88d0e1a8]{flex-direction:column;max-width:520px;display:flex}.sop-cell b[data-v-88d0e1a8]{font-size:14px}.sop-cell span[data-v-88d0e1a8]{color:var(--muted);white-space:nowrap;text-overflow:ellipsis;margin-top:5px;font-size:12px;overflow:hidden}.waiting[data-v-88d0e1a8]{color:#8a641e}.row-actions[data-v-88d0e1a8]{justify-content:flex-end;gap:7px;display:flex}.review-empty[data-v-88d0e1a8]{color:#9aa59f;flex-direction:column;justify-content:center;align-items:center;min-height:220px;display:flex}.review-empty>span[data-v-88d0e1a8]:first-child{color:#4eaa88;font-size:34px}.review-empty b[data-v-88d0e1a8]{color:#415049;margin-top:12px}.review-empty span[data-v-88d0e1a8]:last-child{margin-top:5px;font-size:12px}.modal-copy[data-v-88d0e1a8]{color:var(--muted);margin:0 0 14px;line-height:1.7}@media (width<=650px){.review-guideline[data-v-88d0e1a8],.queue-count[data-v-88d0e1a8]{display:none}}

File diff suppressed because one or more lines are too long

View File

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

View File

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

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,Y as c,j as l,k as u,lt as d,n as f,r as p,t as m,tt as h,vt as g}from"./client-CO11mUW5.js";import{a as _}from"./config-provider-kwhtQ-D4.js";var v={class:`page-shell`},y={class:`surface`},b={key:0},x=p(r({__name:`RunHistoryView`,setup(r){let p=g(!1),x=g([]);async function S(){p.value=!0;try{x.value=(await m.get(`/runs`)).items}catch(e){_.error(f(e))}finally{p.value=!1}}function C(e){return e===`completed`?`已完成`:`进行中`}return c(S),(r,c)=>{let f=h(`a-tag`),m=h(`a-table`);return o(),l(`div`,v,[c[1]||=i(`div`,{class:`page-heading`},[i(`div`,null,[i(`h1`,null,`执行记录`),i(`p`,null,`查看 SOP 的实际使用情况和执行结果。`)])],-1),i(`section`,y,[n(m,{"row-key":`id`,loading:p.value,"data-source":x.value,pagination:{pageSize:20},columns:[{title:`执行编号`,dataIndex:`id`,width:130},{title:`SOP`,dataIndex:`sop_name`},{title:`状态`,dataIndex:`status`,width:110},{title:`结果`,dataIndex:`result`,width:120},{title:`开始时间`,dataIndex:`started_at`,width:190},{title:`完成时间`,dataIndex:`completed_at`,width:190}],scroll:{x:900}},{bodyCell:d(({column:n,record:r})=>[n.dataIndex===`id`?(o(),l(`code`,b,`RUN-`+t(String(r.id).padStart(5,`0`)),1)):n.dataIndex===`status`?(o(),u(f,{key:1,color:r.status===`completed`?`green`:`orange`},{default:d(()=>[a(t(C(r.status)),1)]),_:2},1032,[`color`])):n.dataIndex===`result`?(o(),l(s,{key:2},[a(t(r.result||`-`),1)],64)):n.dataIndex===`started_at`||n.dataIndex===`completed_at`?(o(),l(s,{key:3},[a(t(r[n.dataIndex]?new Date(r[n.dataIndex]).toLocaleString(`zh-CN`):`-`),1)],64)):e(``,!0)]),emptyText:d(()=>[...c[0]||=[i(`div`,{class:`empty-copy`},`还没有执行记录。发布 SOP 后即可开始执行。`,-1)]]),_:1},8,[`loading`,`data-source`])])])}}}),[[`__scopeId`,`data-v-31292c9e`]]);export{x as default};

View File

@@ -1 +0,0 @@
code[data-v-31292c9e]{color:#17654f;background:#eef5f2;border-radius:3px;padding:2px 5px}

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

View File

@@ -1 +0,0 @@
.editor-page[data-v-a5b6c482]{background:#eef1ef;min-height:calc(100vh - 62px)}.editor-header[data-v-a5b6c482]{border-bottom:1px solid var(--line);z-index:10;background:#fff;justify-content:space-between;align-items:center;gap:16px;min-height:66px;padding:10px 24px;display:flex;position:sticky;top:62px}.editor-title[data-v-a5b6c482]{align-items:center;gap:8px;display:flex}.editor-title>div[data-v-a5b6c482]{flex-direction:column;display:flex}.editor-title b[data-v-a5b6c482]{font-size:15px}.editor-title span[data-v-a5b6c482]{color:var(--muted);margin-top:3px;font-size:11px}.editor-workbench[data-v-a5b6c482]{grid-template-columns:280px minmax(0,1fr);gap:16px;max-width:1500px;margin:0 auto;padding:18px;display:grid}.node-rail[data-v-a5b6c482]{align-self:start;position:sticky;top:146px;overflow:hidden}.rail-title[data-v-a5b6c482]{border-bottom:1px solid var(--line);justify-content:space-between;align-items:center;height:50px;padding:0 14px;font-weight:650;display:flex}.node-list[data-v-a5b6c482]{padding:8px}.node-list button[data-v-a5b6c482]{text-align:left;cursor:pointer;background:0 0;border:1px solid #0000;border-radius:4px;grid-template-columns:34px 1fr auto;align-items:center;gap:9px;width:100%;min-height:58px;padding:7px 8px;display:grid}.node-list button[data-v-a5b6c482]:hover{background:#f5f8f6}.node-list button.active[data-v-a5b6c482]{background:#edf6f2;border-color:#b9d8cc}.node-index[data-v-a5b6c482]{color:#88958f;font-family:monospace;font-size:11px}.node-meta[data-v-a5b6c482]{flex-direction:column;min-width:0;display:flex}.node-meta b[data-v-a5b6c482]{text-overflow:ellipsis;white-space:nowrap;font-size:13px;overflow:hidden}.node-meta small[data-v-a5b6c482]{color:var(--muted);margin-top:3px;font-size:11px}.node-move[data-v-a5b6c482]{color:#87938e;gap:4px;display:flex}.node-move[data-v-a5b6c482]>:hover{color:var(--green)}.node-editor[data-v-a5b6c482]{align-self:start;min-height:600px;padding:24px}.node-editor-head[data-v-a5b6c482]{border-bottom:1px solid var(--line);justify-content:space-between;align-items:flex-start;padding-bottom:18px;display:flex}.node-editor-head h2[data-v-a5b6c482]{margin:6px 0 0;font-family:Noto Serif SC,serif;font-size:22px}.node-type-mark[data-v-a5b6c482]{color:var(--green);font-size:10px;font-weight:750}.property-form[data-v-a5b6c482]{padding-top:22px}.property-grid[data-v-a5b6c482]{grid-template-columns:1fr 1fr;gap:16px;display:grid}.transition-head[data-v-a5b6c482]{border-top:1px solid var(--line);justify-content:space-between;margin:8px -24px 0;padding:18px 24px 10px;display:flex}.transition-head>div[data-v-a5b6c482]{flex-direction:column;display:flex}.transition-head span[data-v-a5b6c482]{color:var(--muted);margin-top:4px;font-size:11px}.transition-row[data-v-a5b6c482]{border-bottom:1px solid #edf0ee;grid-template-columns:20px minmax(120px,1fr) auto 36px;align-items:center;gap:10px;min-height:48px;display:grid}.route-line[data-v-a5b6c482]{background:var(--green);width:16px;height:1px}.route-target[data-v-a5b6c482]{font-weight:600}.route-empty[data-v-a5b6c482]{color:var(--muted);padding:20px 0;font-size:12px}.edge-builder[data-v-a5b6c482]{grid-template-columns:1.1fr 1fr 120px 1fr auto;gap:8px;padding-top:16px;display:grid}.editor-empty[data-v-a5b6c482]{place-items:center;min-height:400px;display:grid}@media (width<=1050px){.edge-builder[data-v-a5b6c482]{grid-template-columns:1fr 1fr}.editor-workbench[data-v-a5b6c482]{grid-template-columns:230px minmax(0,1fr)}}@media (width<=760px){.editor-header[data-v-a5b6c482]{flex-direction:column;align-items:flex-start;padding:12px;top:62px}.editor-workbench[data-v-a5b6c482]{grid-template-columns:1fr;padding:10px}.node-rail[data-v-a5b6c482]{position:static}.property-grid[data-v-a5b6c482],.edge-builder[data-v-a5b6c482]{grid-template-columns:1fr}.node-editor[data-v-a5b6c482]{padding:16px}.transition-head[data-v-a5b6c482]{margin:8px -16px 0;padding:16px}}

View File

@@ -0,0 +1 @@
.editor-page[data-v-d8fcde6e]{background:#eef1ef;min-height:calc(100vh - 62px)}.editor-header[data-v-d8fcde6e]{border-bottom:1px solid var(--line);z-index:10;background:#fff;justify-content:space-between;align-items:center;gap:16px;min-height:66px;padding:10px 24px;display:flex;position:sticky;top:62px}.editor-title[data-v-d8fcde6e]{align-items:center;gap:8px;display:flex}.editor-title>div[data-v-d8fcde6e]{flex-direction:column;display:flex}.editor-title b[data-v-d8fcde6e]{font-size:15px}.editor-title span[data-v-d8fcde6e]{color:var(--muted);margin-top:3px;font-size:11px}.editor-workbench[data-v-d8fcde6e]{grid-template-columns:280px minmax(0,1fr);gap:16px;max-width:1500px;margin:0 auto;padding:18px;display:grid}.node-rail[data-v-d8fcde6e]{align-self:start;position:sticky;top:146px;overflow:hidden}.rail-title[data-v-d8fcde6e]{border-bottom:1px solid var(--line);justify-content:space-between;align-items:center;height:50px;padding:0 14px;font-weight:650;display:flex}.node-list[data-v-d8fcde6e]{padding:8px}.node-list button[data-v-d8fcde6e]{text-align:left;cursor:pointer;background:0 0;border:1px solid #0000;border-radius:4px;grid-template-columns:34px 1fr auto;align-items:center;gap:9px;width:100%;min-height:58px;padding:7px 8px;display:grid}.node-list button[data-v-d8fcde6e]:hover{background:#f5f8f6}.node-list button.active[data-v-d8fcde6e]{background:#edf6f2;border-color:#b9d8cc}.node-index[data-v-d8fcde6e]{color:#88958f;font-family:monospace;font-size:11px}.node-meta[data-v-d8fcde6e]{flex-direction:column;min-width:0;display:flex}.node-meta b[data-v-d8fcde6e]{text-overflow:ellipsis;white-space:nowrap;font-size:13px;overflow:hidden}.node-meta small[data-v-d8fcde6e]{color:var(--muted);margin-top:3px;font-size:11px}.node-move[data-v-d8fcde6e]{color:#87938e;gap:4px;display:flex}.node-move[data-v-d8fcde6e]>:hover{color:var(--green)}.node-editor[data-v-d8fcde6e]{align-self:start;min-height:600px;padding:24px}.node-editor-head[data-v-d8fcde6e]{border-bottom:1px solid var(--line);justify-content:space-between;align-items:flex-start;padding-bottom:18px;display:flex}.node-editor-head h2[data-v-d8fcde6e]{margin:6px 0 0;font-family:Noto Serif SC,serif;font-size:22px}.node-type-mark[data-v-d8fcde6e]{color:var(--green);font-size:10px;font-weight:750}.property-form[data-v-d8fcde6e]{padding-top:22px}.property-grid[data-v-d8fcde6e]{grid-template-columns:1fr 1fr;gap:16px;display:grid}.transition-head[data-v-d8fcde6e]{border-top:1px solid var(--line);justify-content:space-between;margin:8px -24px 0;padding:18px 24px 10px;display:flex}.transition-head>div[data-v-d8fcde6e]{flex-direction:column;display:flex}.transition-head span[data-v-d8fcde6e]{color:var(--muted);margin-top:4px;font-size:11px}.transition-row[data-v-d8fcde6e]{border-bottom:1px solid #edf0ee;grid-template-columns:20px minmax(120px,1fr) auto 36px;align-items:center;gap:10px;min-height:48px;display:grid}.route-line[data-v-d8fcde6e]{background:var(--green);width:16px;height:1px}.route-target[data-v-d8fcde6e]{font-weight:600}.route-empty[data-v-d8fcde6e]{color:var(--muted);padding:20px 0;font-size:12px}.edge-builder[data-v-d8fcde6e]{grid-template-columns:1.1fr 1fr 120px 1fr auto;gap:8px;padding-top:16px;display:grid}.editor-empty[data-v-d8fcde6e]{place-items:center;min-height:400px;display:grid}@media (width<=1050px){.edge-builder[data-v-d8fcde6e]{grid-template-columns:1fr 1fr}.editor-workbench[data-v-d8fcde6e]{grid-template-columns:230px minmax(0,1fr)}}@media (width<=760px){.editor-header[data-v-d8fcde6e]{flex-direction:column;align-items:flex-start;padding:12px;top:62px}.editor-workbench[data-v-d8fcde6e]{grid-template-columns:1fr;padding:10px}.node-rail[data-v-d8fcde6e]{position:static}.property-grid[data-v-d8fcde6e],.edge-builder[data-v-d8fcde6e]{grid-template-columns:1fr}.node-editor[data-v-d8fcde6e]{padding:16px}.transition-head[data-v-d8fcde6e]{margin:8px -16px 0;padding:16px}}

File diff suppressed because one or more lines are too long

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:`M866.9 169.9L527.1 54.1C523 52.7 517.5 52 512 52s-11 .7-15.1 2.1L157.1 169.9c-8.3 2.8-15.1 12.4-15.1 21.2v482.4c0 8.8 5.7 20.4 12.6 25.9L499.3 968c3.5 2.7 8 4.1 12.6 4.1s9.2-1.4 12.6-4.1l344.7-268.6c6.9-5.4 12.6-17 12.6-25.9V191.1c.2-8.8-6.6-18.3-14.9-21.2zM810 654.3L512 886.5 214 654.3V226.7l298-101.6 298 101.6v427.6zm-405.8-201c-3-4.1-7.8-6.6-13-6.6H336c-6.5 0-10.3 7.4-6.5 12.7l126.4 174a16.1 16.1 0 0026 0l212.6-292.7c3.8-5.3 0-12.7-6.5-12.7h-55.2c-5.1 0-10 2.5-13 6.6L468.9 542.4l-64.7-89.1z`}}]},name:`safety-certificate`,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=`SafetyCertificateOutlined`,a.inheritAttrs=!1;export{a as t};

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
.back-link[data-v-f755c473]{color:var(--muted);margin:-4px 0 12px -14px}.detail-heading[data-v-f755c473]{color:#fff;background:#202825;border-radius:6px;justify-content:space-between;align-items:flex-end;gap:24px;min-height:150px;padding:28px;display:flex}.detail-heading h1[data-v-f755c473]{letter-spacing:0;margin:10px 0 8px;font-family:Noto Serif SC,serif;font-size:28px}.detail-heading p[data-v-f755c473]{color:#b8c4bf;max-width:720px;margin:0}.heading-tags[data-v-f755c473]{color:#a8b6b0;align-items:center;gap:10px;font-size:12px;display:flex}.scenario-code[data-v-f755c473]{color:#70817a;align-self:flex-start;font-size:12px}.detail-tabs[data-v-f755c473]{margin-top:18px}.toolbar>div[data-v-f755c473]{align-items:baseline;gap:12px;display:flex}.toolbar-note[data-v-f755c473]{color:var(--muted);font-size:12px}.field-grid[data-v-f755c473]{grid-template-columns:1fr 1fr;gap:14px;display:grid}code[data-v-f755c473]{color:#17654f;background:#eef5f2;border-radius:3px;padding:2px 5px}.sop-list button[data-v-f755c473]{text-align:left;border:0;border-bottom:1px solid var(--line);cursor:pointer;background:#fff;grid-template-columns:42px 1fr auto 100px;align-items:center;gap:14px;width:100%;min-height:74px;padding:12px 18px;display:grid}.sop-list button[data-v-f755c473]:hover{background:#f8faf9}.sop-icon[data-v-f755c473]{width:38px;height:38px;color:var(--green);background:#e8f3ef;border-radius:4px;place-items:center;display:grid}.sop-copy[data-v-f755c473]{flex-direction:column;min-width:0;display:flex}.sop-copy small[data-v-f755c473]{color:var(--muted);text-overflow:ellipsis;white-space:nowrap;margin-top:4px;overflow:hidden}.sop-date[data-v-f755c473]{color:var(--muted);text-align:right;font-size:12px}.empty-state[data-v-f755c473]{color:#82908a;text-align:center;padding:64px 20px}.empty-state>span[data-v-f755c473]{font-size:32px}.empty-state p[data-v-f755c473]{margin:12px 0 0}@media (width<=650px){.detail-heading[data-v-f755c473]{flex-direction:column;align-items:flex-start}.scenario-code[data-v-f755c473]{display:none}.field-grid[data-v-f755c473]{grid-template-columns:1fr;gap:0}.sop-list button[data-v-f755c473]{grid-template-columns:36px 1fr auto}.sop-date[data-v-f755c473]{display:none}}

View File

@@ -1 +0,0 @@
.back-link[data-v-4169a902]{color:var(--muted);margin:-4px 0 12px -14px}.detail-heading[data-v-4169a902]{color:#fff;background:#202825;border-radius:6px;justify-content:space-between;align-items:flex-end;gap:24px;min-height:150px;padding:28px;display:flex}.detail-heading h1[data-v-4169a902]{letter-spacing:0;margin:10px 0 8px;font-family:Noto Serif SC,serif;font-size:28px}.detail-heading p[data-v-4169a902]{color:#b8c4bf;max-width:720px;margin:0}.heading-tags[data-v-4169a902]{color:#a8b6b0;align-items:center;gap:10px;font-size:12px;display:flex}.scenario-code[data-v-4169a902]{color:#70817a;align-self:flex-start;font-size:12px}.detail-tabs[data-v-4169a902]{margin-top:18px}.toolbar>div[data-v-4169a902]{align-items:baseline;gap:12px;display:flex}.toolbar-note[data-v-4169a902]{color:var(--muted);font-size:12px}.field-grid[data-v-4169a902]{grid-template-columns:1fr 1fr;gap:14px;display:grid}code[data-v-4169a902]{color:#17654f;background:#eef5f2;border-radius:3px;padding:2px 5px}.sop-list button[data-v-4169a902]{text-align:left;border:0;border-bottom:1px solid var(--line);cursor:pointer;background:#fff;grid-template-columns:42px 1fr auto 100px;align-items:center;gap:14px;width:100%;min-height:74px;padding:12px 18px;display:grid}.sop-list button[data-v-4169a902]:hover{background:#f8faf9}.sop-icon[data-v-4169a902]{width:38px;height:38px;color:var(--green);background:#e8f3ef;border-radius:4px;place-items:center;display:grid}.sop-copy[data-v-4169a902]{flex-direction:column;min-width:0;display:flex}.sop-copy small[data-v-4169a902]{color:var(--muted);text-overflow:ellipsis;white-space:nowrap;margin-top:4px;overflow:hidden}.sop-date[data-v-4169a902]{color:var(--muted);text-align:right;font-size:12px}.empty-state[data-v-4169a902]{color:#82908a;text-align:center;padding:64px 20px}.empty-state>span[data-v-4169a902]{font-size:32px}.empty-state p[data-v-4169a902]{margin:12px 0 0}@media (width<=650px){.detail-heading[data-v-4169a902]{flex-direction:column;align-items:flex-start}.scenario-code[data-v-4169a902]{display:none}.field-grid[data-v-4169a902]{grid-template-columns:1fr;gap:0}.sop-list button[data-v-4169a902]{grid-template-columns:36px 1fr auto}.sop-date[data-v-4169a902]{display:none}}

View File

@@ -1 +0,0 @@
.scenario-name[data-v-3965a2e5]{cursor:pointer;flex-direction:column;max-width:460px;display:flex}.scenario-name b[data-v-3965a2e5]{font-size:14px}.scenario-name small[data-v-3965a2e5]{color:var(--muted);text-overflow:ellipsis;white-space:nowrap;margin-top:4px;overflow:hidden}.form-grid[data-v-3965a2e5]{grid-template-columns:1fr 1fr;gap:14px;display:grid}[data-v-3965a2e5] .ant-table-row{cursor:pointer}@media (width<=600px){.form-grid[data-v-3965a2e5]{grid-template-columns:1fr;gap:0}}

View File

@@ -0,0 +1 @@
.scenario-name[data-v-4642c539]{cursor:pointer;flex-direction:column;max-width:460px;display:flex}.scenario-name b[data-v-4642c539]{font-size:14px}.scenario-name small[data-v-4642c539]{color:var(--muted);text-overflow:ellipsis;white-space:nowrap;margin-top:4px;overflow:hidden}.form-grid[data-v-4642c539]{grid-template-columns:1fr 1fr;gap:14px;display:grid}[data-v-4642c539] .ant-table-row{cursor:pointer}@media (width<=600px){.form-grid[data-v-4642c539]{grid-template-columns:1fr;gap:0}}

View File

@@ -1 +0,0 @@
import{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,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-CO11mUW5.js";import{a as y}from"./config-provider-kwhtQ-D4.js";import{t as b}from"./SearchOutlined-DRcWUFRC.js";import{t as x}from"./PlusOutlined-5Urx-Evx.js";import{h as S}from"./useApi-CROJJdhE-BlzMTLF9.js";var C={class:`page-shell`},w={class:`page-heading`},T={class:`surface`},E={class:`toolbar`},D={class:`muted`},O={key:0,class:`scenario-name`},k={class:`form-grid`},A=h(r({__name:`ScenariosView`,setup(r){let h=S(),A=v(!1),j=v(!1),M=v(``),N=v([]),P=u({name:``,industry:``,role_name:``,goal:``,trigger_text:``,visibility:`tenant`}),F=[{title:`场景名称`,dataIndex:`name`,key:`name`},{title:`行业`,dataIndex:`industry`,key:`industry`,width:140},{title:`适用角色`,dataIndex:`role_name`,key:`role_name`,width:160},{title:`状态`,dataIndex:`status`,key:`status`,width:110},{title:`更新时间`,dataIndex:`updated_at`,key:`updated_at`,width:180},{title:``,key:`action`,width:80}];async function I(){A.value=!0;try{N.value=(await g.get(`/scenarios`,{params:{keyword:M.value}})).items}finally{A.value=!1}}async function L(){try{let e=await g.post(`/scenarios`,P);y.success(`场景已创建`),j.value=!1,h.push(`/scenarios/${e.id}`)}catch(e){y.error(m(e))}}function R(e){return{draft:`草稿`,active:`使用中`,archived:`已归档`}[e]||e}return l(I),(r,l)=>{let u=_(`a-button`),m=_(`a-input`),g=_(`a-tag`),v=_(`a-table`),y=_(`a-form-item`),S=_(`a-textarea`),z=_(`a-radio`),B=_(`a-radio-group`),V=_(`a-form`),H=_(`a-drawer`);return o(),d(`div`,C,[i(`div`,w,[l[10]||=i(`div`,null,[i(`h1`,null,`场景与 SOP`),i(`p`,null,`从业务触发点开始,定义信息字段与可执行话术流程。`)],-1),n(u,{type:`primary`,onClick:l[0]||=e=>j.value=!0},{default:p(()=>[n(c(x)),l[9]||=a(`新建场景`,-1)]),_:1})]),i(`section`,T,[i(`div`,E,[n(m,{value:M.value,"onUpdate:value":l[1]||=e=>M.value=e,"allow-clear":``,placeholder:`搜索名称或行业`,style:{width:`280px`},onPressEnter:I},{prefix:p(()=>[n(c(b))]),_:1},8,[`value`]),i(`span`,D,``+t(N.value.length)+` 个场景`,1)]),n(v,{columns:F,"data-source":N.value,loading:A.value,"row-key":`id`,pagination:!1,scroll:{x:800},"custom-row":e=>({onClick:()=>c(h).push(`/scenarios/${e.id}`)})},{bodyCell:p(({column:n,record:r})=>[n.key===`name`?(o(),d(`div`,O,[i(`b`,null,t(r.name),1),i(`small`,null,t(r.goal),1)])):n.key===`status`?(o(),f(g,{key:1,color:r.status===`active`?`green`:`default`},{default:p(()=>[a(t(R(r.status)),1)]),_:2},1032,[`color`])):n.key===`updated_at`?(o(),d(s,{key:2},[a(t(new Date(r.updated_at).toLocaleString(`zh-CN`)),1)],64)):n.key===`action`?(o(),f(u,{key:3,type:`link`,size:`small`},{default:p(()=>[...l[11]||=[a(`配置`,-1)]]),_:1})):e(``,!0)]),emptyText:p(()=>[...l[12]||=[i(`div`,{class:`empty-copy`},`还没有业务场景。创建第一个场景后,就可以配置字段和 SOP。`,-1)]]),_:1},8,[`data-source`,`loading`,`custom-row`])]),n(H,{open:j.value,"onUpdate:open":l[8]||=e=>j.value=e,title:`创建业务场景`,width:`520`,"destroy-on-close":!0},{default:p(()=>[n(V,{layout:`vertical`,model:P},{default:p(()=>[n(y,{label:`场景名称`,name:`name`,rules:[{required:!0,message:`请输入场景名称`}]},{default:p(()=>[n(m,{value:P.name,"onUpdate:value":l[2]||=e=>P.name=e,placeholder:`例如:宠物医生问诊问药`},null,8,[`value`])]),_:1}),i(`div`,k,[n(y,{label:`所属行业`,name:`industry`,rules:[{required:!0,message:`请输入行业`}]},{default:p(()=>[n(m,{value:P.industry,"onUpdate:value":l[3]||=e=>P.industry=e,placeholder:`宠物医疗`},null,8,[`value`])]),_:1}),n(y,{label:`适用角色`,name:`role_name`,rules:[{required:!0,message:`请输入角色`}]},{default:p(()=>[n(m,{value:P.role_name,"onUpdate:value":l[4]||=e=>P.role_name=e,placeholder:`医生、客服`},null,8,[`value`])]),_:1})]),n(y,{label:`执行目标`,name:`goal`,rules:[{required:!0,message:`请输入执行目标`}]},{default:p(()=>[n(S,{value:P.goal,"onUpdate:value":l[5]||=e=>P.goal=e,rows:3,placeholder:`这个场景最终要推动什么结果`},null,8,[`value`])]),_:1}),n(y,{label:`触发条件`,name:`trigger_text`,rules:[{required:!0,message:`请输入触发条件`}]},{default:p(()=>[n(S,{value:P.trigger_text,"onUpdate:value":l[6]||=e=>P.trigger_text=e,rows:3,placeholder:`什么时候进入这个场景`},null,8,[`value`])]),_:1}),n(y,{label:`可见范围`},{default:p(()=>[n(B,{value:P.visibility,"onUpdate:value":l[7]||=e=>P.visibility=e},{default:p(()=>[n(z,{value:`tenant`},{default:p(()=>[...l[13]||=[a(`全企业`,-1)]]),_:1}),n(z,{value:`team`},{default:p(()=>[...l[14]||=[a(`团队`,-1)]]),_:1}),n(z,{value:`private`},{default:p(()=>[...l[15]||=[a(`仅自己`,-1)]]),_:1})]),_:1},8,[`value`])]),_:1}),n(u,{type:`primary`,block:``,onClick:L},{default:p(()=>[...l[16]||=[a(`创建并继续配置`,-1)]]),_:1})]),_:1},8,[`model`])]),_:1},8,[`open`])])}}}),[[`__scopeId`,`data-v-3965a2e5`]]);export{A as default};

File diff suppressed because one or more lines are too long

View File

@@ -1 +1 @@
import{F as e,a as t}from"./client-CO11mUW5.js";var n={icon:{tag:`svg`,attrs:{viewBox:`64 64 896 896`,focusable:`false`},children:[{tag:`path`,attrs:{d:`M909.6 854.5L649.9 594.8C690.2 542.7 712 479 712 412c0-80.2-31.3-155.4-87.9-212.1-56.6-56.7-132-87.9-212.1-87.9s-155.5 31.3-212.1 87.9C143.2 256.5 112 331.8 112 412c0 80.1 31.3 155.5 87.9 212.1C256.5 680.8 331.8 712 412 712c67 0 130.6-21.8 182.7-62l259.7 259.6a8.2 8.2 0 0011.6 0l43.6-43.5a8.2 8.2 0 000-11.6zM570.4 570.4C528 612.7 471.8 636 412 636s-116-23.3-158.4-65.6C211.3 528 188 471.8 188 412s23.3-116.1 65.6-158.4C296 211.3 352.2 188 412 188s116.1 23.2 158.4 65.6S636 352.2 636 412s-23.3 116.1-65.6 158.4z`}}]},name:`search`,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=`SearchOutlined`,a.inheritAttrs=!1;export{a as t};
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:`M909.6 854.5L649.9 594.8C690.2 542.7 712 479 712 412c0-80.2-31.3-155.4-87.9-212.1-56.6-56.7-132-87.9-212.1-87.9s-155.5 31.3-212.1 87.9C143.2 256.5 112 331.8 112 412c0 80.1 31.3 155.5 87.9 212.1C256.5 680.8 331.8 712 412 712c67 0 130.6-21.8 182.7-62l259.7 259.6a8.2 8.2 0 0011.6 0l43.6-43.5a8.2 8.2 0 000-11.6zM570.4 570.4C528 612.7 471.8 636 412 636s-116-23.3-158.4-65.6C211.3 528 188 471.8 188 412s23.3-116.1 65.6-158.4C296 211.3 352.2 188 412 188s116.1 23.2 158.4 65.6S636 352.2 636 412s-23.3 116.1-65.6 158.4z`}}]},name:`search`,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=`SearchOutlined`,a.inheritAttrs=!1;export{a as t};

View File

@@ -0,0 +1 @@
import{F as e,a as t}from"./client-Bk7n1O1W.js";var n={icon:{tag:`svg`,attrs:{viewBox:`64 64 896 896`,focusable:`false`},children:[{tag:`path`,attrs:{d:`M257.7 752c2 0 4-.2 6-.5L431.9 722c2-.4 3.9-1.3 5.3-2.8l423.9-423.9a9.96 9.96 0 000-14.1L694.9 114.9c-1.9-1.9-4.4-2.9-7.1-2.9s-5.2 1-7.1 2.9L256.8 538.8c-1.5 1.5-2.4 3.3-2.8 5.3l-29.5 168.2a33.5 33.5 0 009.4 29.8c6.6 6.4 14.9 9.9 23.8 9.9zm67.4-174.4L687.8 215l73.3 73.3-362.7 362.6-88.9 15.7 15.6-89zM880 836H144c-17.7 0-32 14.3-32 32v36c0 4.4 3.6 8 8 8h784c4.4 0 8-3.6 8-8v-36c0-17.7-14.3-32-32-32z`}}]},name:`edit`,theme:`outlined`};function r(e){for(var t=1;t<arguments.length;t++){var n=arguments[t]==null?{}:Object(arguments[t]),r=Object.keys(n);typeof Object.getOwnPropertySymbols==`function`&&(r=r.concat(Object.getOwnPropertySymbols(n).filter(function(e){return Object.getOwnPropertyDescriptor(n,e).enumerable}))),r.forEach(function(t){i(e,t,n[t])})}return e}function i(e,t,n){return t in e?Object.defineProperty(e,t,{value:n,enumerable:!0,configurable:!0,writable:!0}):e[t]=n,e}var a=function(i,a){var o=r({},i,a.attrs);return e(t,r({},o,{icon:n}),null)};a.displayName=`EditOutlined`,a.inheritAttrs=!1;var o={icon:{tag:`svg`,attrs:{viewBox:`64 64 896 896`,focusable:`false`},children:[{tag:`path`,attrs:{d:`M824.2 699.9a301.55 301.55 0 00-86.4-60.4C783.1 602.8 812 546.8 812 484c0-110.8-92.4-201.7-203.2-200-109.1 1.7-197 90.6-197 200 0 62.8 29 118.8 74.2 155.5a300.95 300.95 0 00-86.4 60.4C345 754.6 314 826.8 312 903.8a8 8 0 008 8.2h56c4.3 0 7.9-3.4 8-7.7 1.9-58 25.4-112.3 66.7-153.5A226.62 226.62 0 01612 684c60.9 0 118.2 23.7 161.3 66.8C814.5 792 838 846.3 840 904.3c.1 4.3 3.7 7.7 8 7.7h56a8 8 0 008-8.2c-2-77-33-149.2-87.8-203.9zM612 612c-34.2 0-66.4-13.3-90.5-37.5a126.86 126.86 0 01-37.5-91.8c.3-32.8 13.4-64.5 36.3-88 24-24.6 56.1-38.3 90.4-38.7 33.9-.3 66.8 12.9 91 36.6 24.8 24.3 38.4 56.8 38.4 91.4 0 34.2-13.3 66.3-37.5 90.5A127.3 127.3 0 01612 612zM361.5 510.4c-.9-8.7-1.4-17.5-1.4-26.4 0-15.9 1.5-31.4 4.3-46.5.7-3.6-1.2-7.3-4.5-8.8-13.6-6.1-26.1-14.5-36.9-25.1a127.54 127.54 0 01-38.7-95.4c.9-32.1 13.8-62.6 36.3-85.6 24.7-25.3 57.9-39.1 93.2-38.7 31.9.3 62.7 12.6 86 34.4 7.9 7.4 14.7 15.6 20.4 24.4 2 3.1 5.9 4.4 9.3 3.2 17.6-6.1 36.2-10.4 55.3-12.4 5.6-.6 8.8-6.6 6.3-11.6-32.5-64.3-98.9-108.7-175.7-109.9-110.9-1.7-203.3 89.2-203.3 199.9 0 62.8 28.9 118.8 74.2 155.5-31.8 14.7-61.1 35-86.5 60.4-54.8 54.7-85.8 126.9-87.8 204a8 8 0 008 8.2h56.1c4.3 0 7.9-3.4 8-7.7 1.9-58 25.4-112.3 66.7-153.5 29.4-29.4 65.4-49.8 104.7-59.7 3.9-1 6.5-4.7 6-8.7z`}}]},name:`team`,theme:`outlined`};function s(e){for(var t=1;t<arguments.length;t++){var n=arguments[t]==null?{}:Object(arguments[t]),r=Object.keys(n);typeof Object.getOwnPropertySymbols==`function`&&(r=r.concat(Object.getOwnPropertySymbols(n).filter(function(e){return Object.getOwnPropertyDescriptor(n,e).enumerable}))),r.forEach(function(t){c(e,t,n[t])})}return e}function c(e,t,n){return t in e?Object.defineProperty(e,t,{value:n,enumerable:!0,configurable:!0,writable:!0}):e[t]=n,e}var l=function(n,r){var i=s({},n,r.attrs);return e(t,s({},i,{icon:o}),null)};l.displayName=`TeamOutlined`,l.inheritAttrs=!1;export{a as n,l as t};

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

@@ -1 +1 @@
import{V as e}from"./client-CO11mUW5.js";function t(e){return typeof e==`object`||`displayName`in e||`props`in e||`__vccOpts`in e}function n(e){return e.__esModule||e[Symbol.toStringTag]===`Module`||e.default&&t(e.default)}var r=Object.assign;function i(e,t){let n={};for(let r in t){let i=t[r];n[r]=o(i)?i.map(e):e(i)}return n}var a=()=>{},o=Array.isArray;function s(e,t){let n={};for(let r in e)n[r]=r in t?t[r]:e[r];return n}var c=Symbol(``);function l(e,t){return r(Error(),{type:e,[c]:!0},t)}function u(e,t){return e instanceof Error&&c in e&&(t==null||!!(e.type&t))}var d=Symbol(``),f=Symbol(``),p=Symbol(``),m=Symbol(``),h=Symbol(``);function g(){return e(p)}function _(t){return e(m)}export{n as a,d as c,m as d,p as f,f as g,g as h,o as i,s as l,_ as m,r as n,u as o,h as p,l as r,t as s,i as t,a as u};
import{V as e}from"./client-Bk7n1O1W.js";function t(e){return typeof e==`object`||`displayName`in e||`props`in e||`__vccOpts`in e}function n(e){return e.__esModule||e[Symbol.toStringTag]===`Module`||e.default&&t(e.default)}var r=Object.assign;function i(e,t){let n={};for(let r in t){let i=t[r];n[r]=o(i)?i.map(e):e(i)}return n}var a=()=>{},o=Array.isArray;function s(e,t){let n={};for(let r in e)n[r]=r in t?t[r]:e[r];return n}var c=Symbol(``);function l(e,t){return r(Error(),{type:e,[c]:!0},t)}function u(e,t){return e instanceof Error&&c in e&&(t==null||!!(e.type&t))}var d=Symbol(``),f=Symbol(``),p=Symbol(``),m=Symbol(``),h=Symbol(``);function g(){return e(p)}function _(t){return e(m)}export{n as a,d as c,m as d,p as f,f as g,g as h,o as i,s as l,_ as m,r as n,u as o,h as p,l as r,t as s,i as t,a as u};

29
web/dist/index.html vendored
View File

@@ -5,19 +5,22 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#202825" />
<title>销冠 SOP 平台</title>
<script type="module" crossorigin src="/assets/index-BETdHWCY.js"></script>
<link rel="modulepreload" crossorigin href="/assets/client-CO11mUW5.js">
<link rel="modulepreload" crossorigin href="/assets/config-provider-kwhtQ-D4.js">
<link rel="modulepreload" crossorigin href="/assets/auth-D7KJ41TQ.js">
<link rel="modulepreload" crossorigin href="/assets/DeleteOutlined-Dsl9pMnk.js">
<link rel="modulepreload" crossorigin href="/assets/SearchOutlined-DRcWUFRC.js">
<link rel="modulepreload" crossorigin href="/assets/PlusOutlined-5Urx-Evx.js">
<link rel="modulepreload" crossorigin href="/assets/ArrowLeftOutlined-m6YdiMlD.js">
<link rel="modulepreload" crossorigin href="/assets/AppstoreOutlined-BOx6VBGQ.js">
<link rel="modulepreload" crossorigin href="/assets/BookOutlined-CNUY9qCc.js">
<link rel="modulepreload" crossorigin href="/assets/PlayCircleOutlined-C2UGH7h6.js">
<link rel="modulepreload" crossorigin href="/assets/useApi-CROJJdhE-BlzMTLF9.js">
<link rel="stylesheet" crossorigin href="/assets/index-BXFcLq5Y.css">
<script type="module" crossorigin src="/assets/index-BNEb9LqG.js"></script>
<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/auth-D6Tj4g1X.js">
<link rel="modulepreload" crossorigin href="/assets/DeleteOutlined-CRNedxr9.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/ReloadOutlined-Cih9hKpY.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/AppstoreOutlined-BD2QJkU0.js">
<link rel="modulepreload" crossorigin href="/assets/TeamOutlined-BjyYKMen.js">
<link rel="modulepreload" crossorigin href="/assets/BookOutlined-Canuw-q-.js">
<link rel="modulepreload" crossorigin href="/assets/PlayCircleOutlined-DjeS67m_.js">
<link rel="modulepreload" crossorigin href="/assets/useApi-CROJJdhE-C8LJOf_8.js">
<link rel="stylesheet" crossorigin href="/assets/index-D6NKEiQp.css">
</head>
<body>
<div id="app"></div>

View File

@@ -1,8 +1,27 @@
import axios, { type AxiosRequestConfig } from 'axios'
import axios, { type AxiosRequestConfig, type InternalAxiosRequestConfig } from 'axios'
interface Envelope<T> { code: string; message: string; data: T }
const client = axios.create({ baseURL: '/api/v1', timeout: 15000 })
let refreshing: Promise<string> | null = null
interface RetryConfig extends InternalAxiosRequestConfig { _retry?: boolean }
function clearSession() {
localStorage.removeItem('access_token')
localStorage.removeItem('refresh_token')
if (window.location.pathname !== '/login') window.location.href = '/login'
}
async function refreshAccessToken(): Promise<string> {
const refreshToken = localStorage.getItem('refresh_token')
if (!refreshToken) throw new Error('refresh token is missing')
const response = await axios.post<Envelope<{ access_token:string; refresh_token:string }>>('/api/v1/auth/refresh', { refresh_token: refreshToken })
const pair = response.data.data
localStorage.setItem('access_token', pair.access_token)
localStorage.setItem('refresh_token', pair.refresh_token)
return pair.access_token
}
client.interceptors.request.use((config) => {
const token = localStorage.getItem('access_token')
@@ -12,11 +31,20 @@ client.interceptors.request.use((config) => {
client.interceptors.response.use(
(response) => response,
(error) => {
if (error.response?.status === 401 && !error.config?.url?.includes('/auth/login')) {
localStorage.removeItem('access_token')
localStorage.removeItem('refresh_token')
if (window.location.pathname !== '/login') window.location.href = '/login'
async (error) => {
const original = error.config as RetryConfig | undefined
const authRequest = original?.url?.includes('/auth/login') || original?.url?.includes('/auth/refresh')
if (error.response?.status === 401 && original && !original._retry && !authRequest) {
original._retry = true
try {
refreshing ||= refreshAccessToken().finally(() => { refreshing = null })
const token = await refreshing
original.headers.Authorization = `Bearer ${token}`
return client(original)
} catch (refreshError) {
clearSession()
return Promise.reject(refreshError)
}
}
return Promise.reject(error)
},

View File

@@ -4,6 +4,7 @@ import { useRoute, useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import {
AppstoreOutlined,
AuditOutlined,
BookOutlined,
DashboardOutlined,
HistoryOutlined,
@@ -11,9 +12,11 @@ import {
MenuFoldOutlined,
MenuUnfoldOutlined,
PlayCircleOutlined,
TeamOutlined,
} from '@ant-design/icons-vue'
const collapsed = ref(false)
const mobileOpen = ref(false)
const route = useRoute()
const router = useRouter()
const auth = useAuthStore()
@@ -22,19 +25,23 @@ const selectedKeys = computed(() => {
if (route.path.startsWith('/scenarios') || route.path.startsWith('/sops')) return ['scenarios']
if (route.path.startsWith('/execute')) return ['execute']
if (route.path.startsWith('/runs')) return ['runs']
if (route.path.startsWith('/reviews')) return ['reviews']
if (route.path.startsWith('/members')) return ['members']
if (route.path.startsWith('/knowledge')) return ['knowledge']
return ['dashboard']
})
const titles: Record<string, string> = {
dashboard: '工作台', scenarios: '场景与 SOP', execute: '执行话术', runs: '执行记录', knowledge: '知识卡',
dashboard: '工作台', scenarios: '场景与 SOP', reviews: '审核中心', execute: '执行话术', runs: '执行记录', knowledge: '知识卡', members: '企业成员',
}
const pageTitle = computed(() => titles[selectedKeys.value[0]] || '销冠 SOP')
const roleText = computed(() => ({admin:'企业管理员',editor:'SOP 编辑者',reviewer:'审核者',operator:'一线执行者'}[auth.user?.role_code||'']||auth.user?.role_code||''))
onMounted(() => auth.loadUser().catch(() => auth.logout()))
function navigate({ key }: { key: string }) {
const target: Record<string, string> = { dashboard: '/', scenarios: '/scenarios', execute: '/execute', runs: '/runs', knowledge: '/knowledge' }
const target: Record<string, string> = { dashboard: '/', scenarios: '/scenarios', reviews: '/reviews', execute: '/execute', runs: '/runs', knowledge: '/knowledge', members: '/members' }
mobileOpen.value = false
router.push(target[key])
}
@@ -56,10 +63,12 @@ function logout() {
</div>
<a-menu mode="inline" theme="dark" :selected-keys="selectedKeys" @click="navigate">
<a-menu-item key="dashboard"><DashboardOutlined /><span>工作台</span></a-menu-item>
<a-menu-item key="scenarios"><AppstoreOutlined /><span>场景与 SOP</span></a-menu-item>
<a-menu-item key="execute"><PlayCircleOutlined /><span>执行话术</span></a-menu-item>
<a-menu-item key="runs"><HistoryOutlined /><span>执行记录</span></a-menu-item>
<a-menu-item key="knowledge"><BookOutlined /><span>知识卡</span></a-menu-item>
<a-menu-item v-if="auth.can('scenario.view')" key="scenarios"><AppstoreOutlined /><span>场景与 SOP</span></a-menu-item>
<a-menu-item v-if="auth.can('sop.review')" key="reviews"><AuditOutlined /><span>审核中心</span></a-menu-item>
<a-menu-item v-if="auth.can('sop.execute')" key="execute"><PlayCircleOutlined /><span>执行话术</span></a-menu-item>
<a-menu-item v-if="auth.can('runs.view_all')||auth.can('runs.view_own')" key="runs"><HistoryOutlined /><span>执行记录</span></a-menu-item>
<a-menu-item v-if="auth.can('knowledge.view')" key="knowledge"><BookOutlined /><span>知识卡</span></a-menu-item>
<a-menu-item v-if="auth.can('member.manage')" key="members"><TeamOutlined /><span>企业成员</span></a-menu-item>
</a-menu>
<div class="sider-foot" :class="{ compact: collapsed }">
<span class="online-dot"></span>
@@ -69,16 +78,19 @@ function logout() {
<a-layout>
<a-layout-header class="topbar">
<a-button type="text" class="collapse-button" :aria-label="collapsed ? '展开导航' : '收起导航'" @click="collapsed = !collapsed">
<a-button type="text" class="collapse-button desktop-nav-toggle" :aria-label="collapsed ? '展开导航' : '收起导航'" @click="collapsed = !collapsed">
<MenuUnfoldOutlined v-if="collapsed" /><MenuFoldOutlined v-else />
</a-button>
<a-button type="text" class="collapse-button mobile-nav-toggle" aria-label="打开导航" @click="mobileOpen = true">
<MenuUnfoldOutlined />
</a-button>
<div class="topbar-title">{{ pageTitle }}</div>
<a-dropdown placement="bottomRight">
<button class="user-button">
<span class="avatar">{{ (auth.user?.display_name || '管').slice(0, 1) }}</span>
<span class="user-copy">
<strong>{{ auth.user?.display_name || '平台管理员' }}</strong>
<small>{{ auth.user?.role_code === 'admin' ? '企业管理员' : auth.user?.role_code }}</small>
<small>{{ roleText }}</small>
</span>
</button>
<template #overlay>
@@ -88,6 +100,22 @@ function logout() {
</a-layout-header>
<a-layout-content class="content-area"><router-view /></a-layout-content>
</a-layout>
<a-drawer v-model:open="mobileOpen" placement="left" :width="280" :closable="false" class="mobile-nav-drawer">
<div class="mobile-brand">
<div class="brand-mark"><span></span><span></span><span></span></div>
<div class="brand-copy"><strong>销冠 SOP</strong><small>经验执行系统</small></div>
</div>
<a-menu mode="inline" theme="dark" :selected-keys="selectedKeys" @click="navigate">
<a-menu-item key="dashboard"><DashboardOutlined /><span>工作台</span></a-menu-item>
<a-menu-item v-if="auth.can('scenario.view')" key="scenarios"><AppstoreOutlined /><span>场景与 SOP</span></a-menu-item>
<a-menu-item v-if="auth.can('sop.review')" key="reviews"><AuditOutlined /><span>审核中心</span></a-menu-item>
<a-menu-item v-if="auth.can('sop.execute')" key="execute"><PlayCircleOutlined /><span>执行话术</span></a-menu-item>
<a-menu-item v-if="auth.can('runs.view_all')||auth.can('runs.view_own')" key="runs"><HistoryOutlined /><span>执行记录</span></a-menu-item>
<a-menu-item v-if="auth.can('knowledge.view')" key="knowledge"><BookOutlined /><span>知识卡</span></a-menu-item>
<a-menu-item v-if="auth.can('member.manage')" key="members"><TeamOutlined /><span>企业成员</span></a-menu-item>
</a-menu>
</a-drawer>
</a-layout>
</template>
@@ -110,6 +138,7 @@ function logout() {
.online-dot { width: 7px; height: 7px; border-radius: 50%; background: #67caa8; box-shadow: 0 0 0 3px rgba(103,202,168,.12); }
.topbar { height: 62px; padding: 0 22px; display: flex; align-items: center; gap: 12px; line-height: normal; background: rgba(255,255,255,.94); border-bottom: 1px solid #dce2df; position: sticky; top: 0; z-index: 20; }
.collapse-button { width: 36px; height: 36px; }
.mobile-nav-toggle { display: none; }
.topbar-title { flex: 1; color: #52605a; font-size: 14px; }
.user-button { display: flex; align-items: center; gap: 9px; border: 0; padding: 6px 8px; background: transparent; cursor: pointer; border-radius: 5px; }
.user-button:hover { background: #f1f4f2; }
@@ -117,5 +146,10 @@ function logout() {
.user-copy { display: flex; flex-direction: column; text-align: left; }
.user-copy strong { font-size: 13px; }.user-copy small { color: #7c8883; font-size: 11px; margin-top: 2px; }
.content-area { min-width: 0; background: #f4f6f5; }
@media (max-width: 760px) { .side-panel { display: none; }.user-copy { display: none; }.topbar { padding: 0 12px; } }
:global(.mobile-nav-drawer .ant-drawer-body) { padding: 0; background: #202825; }
.mobile-brand { height: 78px; display: flex; align-items: center; gap: 12px; padding: 0 20px; border-bottom: 1px solid rgba(255,255,255,.09); }
.mobile-nav-drawer :deep(.ant-menu-dark) { min-height: calc(100vh - 78px); padding: 12px 9px; background: #202825; }
.mobile-nav-drawer :deep(.ant-menu-item) { height: 42px; margin: 5px 0; border-radius: 4px; color: #b9c4c0; }
.mobile-nav-drawer :deep(.ant-menu-item-selected) { background: #166b53 !important; color: white; }
@media (max-width: 760px) { .side-panel { display: none; }.desktop-nav-toggle { display: none; }.mobile-nav-toggle { display: inline-flex; }.user-copy { display: none; }.topbar { padding: 0 12px; } }
</style>

View File

@@ -15,6 +15,9 @@ const router = createRouter({
{ path: 'sops/:id', name: 'sop-editor', component: () => import('@/views/SOPEditorView.vue') },
{ path: 'execute', name: 'execute', component: () => import('@/views/ExecuteView.vue') },
{ path: 'runs', name: 'runs', component: () => import('@/views/RunHistoryView.vue') },
{ path: 'runs/:id', name: 'run-detail', component: () => import('@/views/RunDetailView.vue') },
{ path: 'reviews', name: 'reviews', component: () => import('@/views/ReviewCenterView.vue') },
{ path: 'members', name: 'members', component: () => import('@/views/MembersView.vue') },
{ path: 'knowledge', name: 'knowledge', component: () => import('@/views/KnowledgeView.vue') },
],
},

View File

@@ -8,6 +8,7 @@ interface LoginResult { access_token: string; refresh_token: string; expires_at:
export const useAuthStore = defineStore('auth', () => {
const user = ref<User | null>(null)
const token = ref(localStorage.getItem('access_token') || '')
const loaded = ref(false)
const authenticated = computed(() => Boolean(token.value))
async function login(username: string, password: string) {
@@ -16,19 +17,26 @@ export const useAuthStore = defineStore('auth', () => {
user.value = result.user
localStorage.setItem('access_token', result.access_token)
localStorage.setItem('refresh_token', result.refresh_token)
loaded.value = true
}
async function loadUser() {
if (!token.value) return
user.value = await api.get<User>('/auth/me')
loaded.value = true
}
function logout() {
token.value = ''
user.value = null
loaded.value = false
localStorage.removeItem('access_token')
localStorage.removeItem('refresh_token')
}
return { user, token, authenticated, login, loadUser, logout }
function can(permission: string) {
return Boolean(user.value?.permissions?.includes('*') || user.value?.permissions?.includes(permission))
}
return { user, token, loaded, authenticated, login, loadUser, logout, can }
})

View File

@@ -1,5 +1,5 @@
export interface BaseEntity { id: number; created_at: string; updated_at: string }
export interface User { user_id: number; tenant_id: number; role_code: string; username: string; display_name: string }
export interface User { user_id: number; tenant_id: number; role_code: string; permissions: string[]; username: string; display_name: string }
export interface Scenario extends BaseEntity { tenant_id: number; name: string; industry: string; role_name: string; goal: string; trigger_text: string; visibility: string; status: string; created_by: number }
export interface ScenarioField extends BaseEntity { scenario_id: number; field_key: string; field_name: string; field_type: string; required: boolean; options: unknown[]; validation: Record<string, unknown>; sort_order: number }
export interface SOP extends BaseEntity { scenario_id: number; name: string; description: string; status: string }
@@ -8,3 +8,9 @@ export interface SOPNode extends BaseEntity { node_key: string; type: string; ti
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 KnowledgeCard extends BaseEntity { scenario_id: number; title: string; 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 RunEvent extends BaseEntity { run_id:number; node_key:string; action:string; payload:Record<string,any>; node_title:string; node_type:string; node_content:string }
export interface RunFeedback extends BaseEntity { run_id:number; user_id:number; score:number; comment:string; user_name:string }
export interface RunDetail extends SOPRun { scenario_name:string; version:number; operator_name:string }
export interface Role extends BaseEntity { name:string; code:string; permissions:string[] }
export interface Member { id:number; user_id:number; username:string; display_name:string; role_code:string; role_name:string; status:string }

View File

@@ -3,8 +3,10 @@ import { onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { ArrowRightOutlined, AppstoreOutlined, CheckCircleOutlined, PlayCircleOutlined, PlusOutlined } from '@ant-design/icons-vue'
import { api } from '@/api/client'
import { useAuthStore } from '@/stores/auth'
const router = useRouter()
const auth = useAuthStore()
const loading = ref(true)
const summary = ref({ scenarios: 0, published_sops: 0, runs: 0, completed_runs: 0 })
@@ -17,7 +19,7 @@ onMounted(async () => {
<div class="page-shell">
<div class="page-heading">
<div><h1>今天从经验开始</h1><p>查看知识沉淀和一线执行的最新状态</p></div>
<div class="page-actions"><a-button type="primary" @click="router.push('/scenarios')"><PlusOutlined />创建场景</a-button></div>
<div v-if="auth.can('scenario.edit')" class="page-actions"><a-button type="primary" @click="router.push('/scenarios')"><PlusOutlined />创建场景</a-button></div>
</div>
<a-skeleton :loading="loading" active>
@@ -32,9 +34,11 @@ onMounted(async () => {
<section class="work-grid">
<div class="surface action-panel">
<div class="panel-kicker">常用入口</div>
<button @click="router.push('/scenarios')"><span>配置新的业务场景<small>定义字段目标和触发条件</small></span><ArrowRightOutlined /></button>
<button @click="router.push('/execute')"><span>开始执行已发布 SOP<small>根据客户回答逐步推进</small></span><ArrowRightOutlined /></button>
<button @click="router.push('/knowledge')"><span>维护审核知识卡<small>统一口径与风险提示</small></span><ArrowRightOutlined /></button>
<button v-if="auth.can('scenario.edit')" @click="router.push('/scenarios')"><span>配置新的业务场景<small>定义字段目标和触发条件</small></span><ArrowRightOutlined /></button>
<button v-if="auth.can('sop.review')" @click="router.push('/reviews')"><span>处理待审核 SOP<small>核对流程口径和风险出口</small></span><ArrowRightOutlined /></button>
<button v-if="auth.can('sop.execute')" @click="router.push('/execute')"><span>开始执行已发布 SOP<small>根据客户回答逐步推进</small></span><ArrowRightOutlined /></button>
<button v-if="auth.can('knowledge.edit')" @click="router.push('/knowledge')"><span>维护审核知识卡<small>统一口径与风险提示</small></span><ArrowRightOutlined /></button>
<button v-if="auth.can('runs.view_all')||auth.can('runs.view_own')" @click="router.push('/runs')"><span>复盘执行记录<small>查看节点轨迹回答与反馈</small></span><ArrowRightOutlined /></button>
</div>
<div class="surface doctrine-panel">
<div class="panel-kicker">平台原则</div>

View File

@@ -8,12 +8,14 @@ import type { ScenarioField, SOPNode, SOPRun } from '@/types'
interface PublishedSOP { id:number; name:string; description:string; scenario_id:number; scenario_name:string; version:number }
interface RunView { run:SOPRun; node:SOPNode; fields:ScenarioField[] }
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 feedback=reactive({score:5,comment:''});const feedbackSent=ref(false)
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[]})
async function load(){loading.value=true;try{sops.value=(await api.get<{items:PublishedSOP[]}>('/published-sops')).items}catch(error){message.error(apiMessage(error))}finally{loading.value=false}}
async function start(sopID:number){try{active.value=await api.post<RunView>('/runs',{sop_id:sopID});Object.keys(answers).forEach(k=>delete answers[k])}catch(error){message.error(apiMessage(error))}}
async function start(sopID:number){try{active.value=await api.post<RunView>('/runs',{sop_id:sopID});Object.keys(answers).forEach(k=>delete answers[k]);Object.assign(feedback,{score:5,comment:''});feedbackSent.value=false}catch(error){message.error(apiMessage(error))}}
async function next(){if(!active.value)return;submitting.value=true;try{active.value=await api.post<RunView>(`/runs/${active.value.run.id}/answer`,{node_key:active.value.node.node_key,answers:{...answers}});Object.keys(answers).forEach(k=>delete answers[k])}catch(error){message.error(apiMessage(error))}finally{submitting.value=false}}
function inputFor(field:ScenarioField){return field.field_type}
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()}
onMounted(load)
</script>
@@ -36,7 +38,7 @@ onMounted(load)
<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="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><a-button type="primary" @click="reset">执行另一套 SOP</a-button></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>
<template v-else>
<a-form layout="vertical" class="answer-form">
<a-form-item v-for="field in currentFields" :key="field.id" :label="field.field_name" :required="field.required">
@@ -60,6 +62,6 @@ onMounted(load)
</template>
<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)}
@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}}
.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}
@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>

View File

@@ -3,8 +3,10 @@ import { computed, onMounted, reactive, ref } from 'vue'
import { BookOutlined, DeleteOutlined, PlusOutlined } from '@ant-design/icons-vue'
import { message, Modal } from 'ant-design-vue'
import { api, apiMessage } from '@/api/client'
import { useAuthStore } from '@/stores/auth'
import type { KnowledgeCard, Scenario } from '@/types'
const loading=ref(false);const open=ref(false);const items=ref<KnowledgeCard[]>([]);const scenarios=ref<Scenario[]>([])
const auth=useAuthStore()
const form=reactive({scenario_id:undefined as number|undefined,title:'',content:'',forbidden:'',risk_note:''})
const scenarioOptions=computed(()=>scenarios.value.map(s=>({value:s.id,label:s.name})))
async function load(){loading.value=true;try{const [cards,sceneData]=await Promise.all([api.get<{items:KnowledgeCard[]}>('/knowledge-cards'),api.get<{items:Scenario[]}>('/scenarios')]);items.value=cards.items;scenarios.value=sceneData.items}catch(error){message.error(apiMessage(error))}finally{loading.value=false}}
@@ -13,5 +15,5 @@ function remove(item:KnowledgeCard){Modal.confirm({title:`归档“${item.title}
function contentOf(item:KnowledgeCard,key:string){return item.content?.[key]||''}
onMounted(load)
</script>
<template><div class="page-shell"><div class="page-heading"><div><h1>知识卡</h1><p>沉淀经过审核的标准表达禁用语和风险提醒</p></div><a-button type="primary" @click="open=true"><PlusOutlined />新建知识卡</a-button></div><a-spin :spinning="loading"><div v-if="items.length" class="knowledge-grid"><article v-for="item in items" :key="item.id" class="knowledge-card surface"><header><span><BookOutlined /></span><a-tag color="green">已发布</a-tag></header><h2>{{ item.title }}</h2><p>{{ contentOf(item,'standard_copy')||'暂无标准话术' }}</p><div v-if="contentOf(item,'risk_note')" class="risk-note">{{ contentOf(item,'risk_note') }}</div><footer><span>{{ scenarios.find(s=>s.id===item.scenario_id)?.name||'未分类场景' }}</span><a-button type="text" danger aria-label="归档知识卡" @click="remove(item)"><DeleteOutlined /></a-button></footer></article></div><a-empty v-else description="还没有知识卡" /></a-spin><a-drawer v-model:open="open" title="新建知识卡" width="520"><a-form layout="vertical" :model="form" @finish="create"><a-form-item label="所属场景" name="scenario_id" :rules="[{required:true,message:'请选择场景'}]"><a-select v-model:value="form.scenario_id" :options="scenarioOptions" /></a-form-item><a-form-item label="标题" name="title" :rules="[{required:true,message:'请输入标题'}]"><a-input v-model:value="form.title" /></a-form-item><a-form-item label="标准话术" name="content" :rules="[{required:true,message:'请输入标准话术'}]"><a-textarea v-model:value="form.content" :rows="5" /></a-form-item><a-form-item label="禁用表达"><a-textarea v-model:value="form.forbidden" :rows="3" /></a-form-item><a-form-item label="风险提醒"><a-textarea v-model:value="form.risk_note" :rows="3" /></a-form-item><a-button type="primary" html-type="submit" block>发布知识卡</a-button></a-form></a-drawer></div></template>
<template><div class="page-shell"><div class="page-heading"><div><h1>知识卡</h1><p>沉淀经过审核的标准表达禁用语和风险提醒</p></div><a-button v-if="auth.can('knowledge.edit')" type="primary" @click="open=true"><PlusOutlined />新建知识卡</a-button></div><a-spin :spinning="loading"><div v-if="items.length" class="knowledge-grid"><article v-for="item in items" :key="item.id" class="knowledge-card surface"><header><span><BookOutlined /></span><a-tag color="green">已发布</a-tag></header><h2>{{ item.title }}</h2><p>{{ contentOf(item,'standard_copy')||'暂无标准话术' }}</p><div v-if="contentOf(item,'risk_note')" class="risk-note">{{ contentOf(item,'risk_note') }}</div><footer><span>{{ scenarios.find(s=>s.id===item.scenario_id)?.name||'未分类场景' }}</span><a-button v-if="auth.can('knowledge.edit')" type="text" danger aria-label="归档知识卡" @click="remove(item)"><DeleteOutlined /></a-button></footer></article></div><a-empty v-else description="还没有知识卡" /></a-spin><a-drawer v-if="auth.can('knowledge.edit')" v-model:open="open" title="新建知识卡" width="520"><a-form layout="vertical" :model="form" @finish="create"><a-form-item label="所属场景" name="scenario_id" :rules="[{required:true,message:'请选择场景'}]"><a-select v-model:value="form.scenario_id" :options="scenarioOptions" /></a-form-item><a-form-item label="标题" name="title" :rules="[{required:true,message:'请输入标题'}]"><a-input v-model:value="form.title" /></a-form-item><a-form-item label="标准话术" name="content" :rules="[{required:true,message:'请输入标准话术'}]"><a-textarea v-model:value="form.content" :rows="5" /></a-form-item><a-form-item label="禁用表达"><a-textarea v-model:value="form.forbidden" :rows="3" /></a-form-item><a-form-item label="风险提醒"><a-textarea v-model:value="form.risk_note" :rows="3" /></a-form-item><a-button type="primary" html-type="submit" block>发布知识卡</a-button></a-form></a-drawer></div></template>
<style scoped>.knowledge-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:14px}.knowledge-card{min-height:250px;padding:20px;display:flex;flex-direction:column}.knowledge-card header{display:flex;justify-content:space-between}.knowledge-card header>span{width:36px;height:36px;display:grid;place-items:center;color:var(--green);background:#e8f3ef;border-radius:4px}.knowledge-card h2{margin:20px 0 10px;font-family:"Noto Serif SC",serif;font-size:19px}.knowledge-card>p{margin:0;color:#4f5c56;line-height:1.7}.risk-note{margin-top:14px;padding:9px 11px;color:#8b5b16;background:#fbf3e5;border-left:2px solid #c68a37;font-size:12px}.knowledge-card footer{display:flex;align-items:center;justify-content:space-between;margin-top:auto;padding-top:18px;color:var(--muted);font-size:12px}@media(max-width:1050px){.knowledge-grid{grid-template-columns:repeat(2,1fr)}}@media(max-width:650px){.knowledge-grid{grid-template-columns:1fr}}</style>

View File

@@ -0,0 +1,33 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { EditOutlined, PlusOutlined, TeamOutlined } from '@ant-design/icons-vue'
import { message } from 'ant-design-vue'
import { api, apiMessage } from '@/api/client'
import { useAuthStore } from '@/stores/auth'
import type { Member, Role } from '@/types'
const auth=useAuthStore();const loading=ref(false);const open=ref(false);const editing=ref<Member|null>(null);const items=ref<Member[]>([]);const roles=ref<Role[]>([]);const saving=ref(false)
const form=reactive({username:'',display_name:'',password:'',role_code:'operator'})
const editForm=reactive({role_code:'operator',status:'active'})
const roleOptions=computed(()=>roles.value.map(role=>({value:role.code,label:role.name})))
const columns=[{title:'成员',key:'member'},{title:'角色',dataIndex:'role_name',width:150},{title:'状态',dataIndex:'status',width:110},{title:'',key:'action',width:80}]
async function load(){loading.value=true;try{const data=await api.get<{items:Member[];roles:Role[]}>('/members');items.value=data.items||[];roles.value=data.roles||[]}catch(error){message.error(apiMessage(error))}finally{loading.value=false}}
async function create(){saving.value=true;try{await api.post('/members',form);message.success('成员已创建');open.value=false;Object.assign(form,{username:'',display_name:'',password:'',role_code:'operator'});await load()}catch(error){message.error(apiMessage(error))}finally{saving.value=false}}
function edit(item:Member){editing.value=item;Object.assign(editForm,{role_code:item.role_code,status:item.status})}
async function update(){if(!editing.value)return;saving.value=true;try{await api.put(`/members/${editing.value.id}`,editForm);message.success('成员权限已更新');editing.value=null;await load()}catch(error){message.error(apiMessage(error))}finally{saving.value=false}}
onMounted(load)
</script>
<template>
<div class="page-shell members-page">
<div class="page-heading"><div><h1>企业成员</h1><p>分配 SOP 编辑审核和一线执行职责权限变更会立即生效</p></div><a-button type="primary" @click="open=true"><PlusOutlined />添加成员</a-button></div>
<section class="member-summary"><TeamOutlined /><div><b>{{ items.filter(item=>item.status==='active').length }}</b><span>在用账号</span></div><p>管理员拥有全部权限编辑者维护场景与草稿审核者负责发布执行者只使用已发布 SOP 和自己的记录</p></section>
<section class="surface"><a-table row-key="id" :loading="loading" :data-source="items" :columns="columns" :pagination="false"><template #bodyCell="{column,record}"><template v-if="column.key==='member'"><div class="member-cell"><span>{{ record.display_name.slice(0,1) }}</span><div><b>{{ record.display_name }}</b><small>{{ record.username }}</small></div></div></template><template v-else-if="column.dataIndex==='role_name'"><a-tag>{{ record.role_name }}</a-tag></template><template v-else-if="column.dataIndex==='status'"><a-tag :color="record.status==='active'?'green':'default'">{{ record.status==='active'?'使用中':'已停用' }}</a-tag></template><template v-else-if="column.key==='action'"><a-button v-if="record.user_id!==auth.user?.user_id" type="text" aria-label="编辑成员权限" @click="edit(record)"><EditOutlined /></a-button></template></template></a-table></section>
<a-drawer v-model:open="open" title="添加企业成员" width="480"><a-form layout="vertical" :model="form" @finish="create"><div class="form-grid"><a-form-item label="登录用户名" name="username" :rules="[{required:true,message:'请输入用户名'}]"><a-input v-model:value="form.username" /></a-form-item><a-form-item label="显示名称" name="display_name" :rules="[{required:true,message:'请输入显示名称'}]"><a-input v-model:value="form.display_name" /></a-form-item></div><a-form-item label="初始密码" name="password" :rules="[{required:true,min:6,message:'密码至少 6 位'}]"><a-input-password v-model:value="form.password" /></a-form-item><a-form-item label="角色" name="role_code" :rules="[{required:true}]"><a-select v-model:value="form.role_code" :options="roleOptions" /></a-form-item><a-button type="primary" html-type="submit" block :loading="saving">创建成员</a-button></a-form></a-drawer>
<a-modal :open="Boolean(editing)" title="调整成员权限" ok-text="保存" :confirm-loading="saving" @ok="update" @cancel="editing=null"><div v-if="editing" class="editing-name"><b>{{ editing.display_name }}</b><span>{{ editing.username }}</span></div><a-form layout="vertical"><a-form-item label="角色"><a-select v-model:value="editForm.role_code" :options="roleOptions" /></a-form-item><a-form-item label="账号状态"><a-segmented v-model:value="editForm.status" :options="[{value:'active',label:'使用中'},{value:'disabled',label:'停用'}]" /></a-form-item></a-form></a-modal>
</div>
</template>
<style scoped>
.members-page{max-width:1180px}.member-summary{min-height:86px;display:grid;grid-template-columns:38px 90px 1fr;align-items:center;gap:14px;margin-bottom:16px;padding:16px 20px;color:white;background:#202825;border-radius:6px}.member-summary>span{font-size:25px;color:#6bc5a7}.member-summary>div{display:flex;flex-direction:column}.member-summary b{font-family:"Noto Serif SC",serif;font-size:24px}.member-summary div span{color:#a9b8b2;font-size:11px}.member-summary p{margin:0;color:#b6c3be;line-height:1.7}.member-cell{display:flex;align-items:center;gap:11px}.member-cell>span{width:34px;height:34px;display:grid;place-items:center;color:#17664f;background:#e5f2ed;border-radius:4px;font-weight:700}.member-cell>div{display:flex;flex-direction:column}.member-cell small{margin-top:3px;color:var(--muted)}.form-grid{display:grid;grid-template-columns:1fr 1fr;gap:12px}.editing-name{display:flex;flex-direction:column;margin-bottom:18px;padding:12px 14px;background:#f4f7f5;border-left:3px solid var(--green)}.editing-name span{margin-top:4px;color:var(--muted);font-size:12px}@media(max-width:650px){.member-summary{grid-template-columns:34px 1fr}.member-summary p{grid-column:1/3}.form-grid{grid-template-columns:1fr;gap:0}}
</style>

View File

@@ -0,0 +1,35 @@
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue'
import { CheckOutlined, ClockCircleOutlined, EyeOutlined, StopOutlined } from '@ant-design/icons-vue'
import { message } from 'ant-design-vue'
import { useRouter } from 'vue-router'
import { api, apiMessage } from '@/api/client'
import type { ReviewItem } from '@/types'
const router=useRouter();const loading=ref(false);const items=ref<ReviewItem[]>([]);const rejecting=ref<ReviewItem|null>(null);const submitting=ref(false);const rejectForm=reactive({reason:''})
const columns=[{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 load(){loading.value=true;try{const data=await api.get<{items:ReviewItem[]}>('/reviews');items.value=data.items||[]}catch(error){message.error(apiMessage(error))}finally{loading.value=false}}
async function approve(item:ReviewItem){submitting.value=true;try{await api.post(`/sops/${item.sop_id}/publish`);message.success(`${item.sop_name}”已通过并发布`);await load()}catch(error){message.error(apiMessage(error))}finally{submitting.value=false}}
function openReject(item:ReviewItem){rejecting.value=item;rejectForm.reason=''}
async function reject(){if(!rejecting.value||!rejectForm.reason.trim())return message.warning('请填写退回原因');submitting.value=true;try{await api.post(`/sops/${rejecting.value.sop_id}/reject`,{reason:rejectForm.reason});message.success('已退回编辑者修改');rejecting.value=null;await load()}catch(error){message.error(apiMessage(error))}finally{submitting.value=false}}
function waiting(value:string){const minutes=Math.max(1,Math.round((Date.now()-new Date(value).getTime())/60000));if(minutes<60)return`${minutes} 分钟`;const hours=Math.round(minutes/60);return hours<48?`${hours} 小时`:`${Math.round(hours/24)} `}
onMounted(load)
</script>
<template>
<div class="page-shell review-page">
<div class="page-heading"><div><h1>审核中心</h1><p>检查流程完整性风险分支和话术口径后再决定发布</p></div><div class="queue-count"><ClockCircleOutlined /><b>{{ items.length }}</b><span>待审核</span></div></div>
<section class="surface review-table">
<div class="review-guideline"><span>审核关注</span><b>字段完整</b><i></i><b>条件可达</b><i></i><b>高风险有出口</b><i></i><b>知识卡已发布</b></div>
<a-table row-key="version_id" :loading="loading" :data-source="items" :columns="columns" :pagination="false" :scroll="{x:850}">
<template #bodyCell="{column,record}"><template v-if="column.key==='sop'"><div class="sop-cell"><b>{{ record.sop_name }}</b><span>{{ record.scenario_name }} · {{ record.description||'暂无说明' }}</span></div></template><template v-else-if="column.dataIndex==='version'"><a-tag>V{{ record.version }}</a-tag></template><template v-else-if="column.dataIndex==='submitted_at'"><span class="waiting">{{ waiting(record.submitted_at) }}</span></template><template v-else-if="column.key==='action'"><div class="row-actions"><a-button @click="router.push(`/sops/${record.sop_id}`)"><EyeOutlined />查看</a-button><a-button danger @click="openReject(record)"><StopOutlined />退回</a-button><a-button type="primary" :loading="submitting" @click="approve(record)"><CheckOutlined />通过</a-button></div></template></template>
<template #emptyText><div class="review-empty"><CheckOutlined /><b>审核队列已清空</b><span>新提交的 SOP 会出现在这里</span></div></template>
</a-table>
</section>
<a-modal :open="Boolean(rejecting)" title="退回修改" ok-text="确认退回" :confirm-loading="submitting" @ok="reject" @cancel="rejecting=null"><p class="modal-copy">请说明{{ rejecting?.sop_name }}需要调整的具体内容编辑者可据此修改后重新提交</p><a-textarea v-model:value="rejectForm.reason" :rows="5" :maxlength="1000" show-count placeholder="例如:急症判断缺少默认路径,请补充非急症分支。" /></a-modal>
</div>
</template>
<style scoped>
.queue-count{height:54px;display:grid;grid-template-columns:24px 32px auto;align-items:center;gap:8px;padding:0 16px;color:#3f4c46;background:#fff;border:1px solid var(--line);border-radius:6px}.queue-count>span:first-child{color:var(--amber)}.queue-count b{font-family:"Noto Serif SC",serif;font-size:24px}.queue-count span:last-child{color:var(--muted);font-size:12px}.review-table{overflow:hidden}.review-guideline{min-height:48px;display:flex;align-items:center;gap:12px;padding:0 16px;color:#637069;background:#f7f9f8;border-bottom:1px solid var(--line);font-size:11px}.review-guideline>span{font-weight:750;color:#28332e}.review-guideline i{width:3px;height:3px;border-radius:50%;background:#a7b2ad}.sop-cell{display:flex;flex-direction:column;max-width:520px}.sop-cell b{font-size:14px}.sop-cell span{margin-top:5px;color:var(--muted);font-size:12px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.waiting{color:#8a641e}.row-actions{display:flex;justify-content:flex-end;gap:7px}.review-empty{min-height:220px;display:flex;flex-direction:column;align-items:center;justify-content:center;color:#9aa59f}.review-empty>span:first-child{font-size:34px;color:#4eaa88}.review-empty b{margin-top:12px;color:#415049}.review-empty span:last-child{margin-top:5px;font-size:12px}.modal-copy{margin:0 0 14px;color:var(--muted);line-height:1.7}@media(max-width:650px){.review-guideline{display:none}.queue-count{display:none}}
</style>

View File

@@ -0,0 +1,65 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { ArrowLeftOutlined, CheckCircleOutlined, ClockCircleOutlined, SafetyCertificateOutlined } from '@ant-design/icons-vue'
import { message } from 'ant-design-vue'
import { useRoute, useRouter } from 'vue-router'
import { api, apiMessage } from '@/api/client'
import { useAuthStore } from '@/stores/auth'
import type { RunDetail, RunEvent, RunFeedback, ScenarioField } from '@/types'
interface DetailResponse{run:RunDetail;events:RunEvent[];feedback:RunFeedback[];fields:ScenarioField[]}
const route=useRoute();const router=useRouter();const auth=useAuthStore();const loading=ref(true);const detail=ref<DetailResponse|null>(null);const submitting=ref(false);const feedback=reactive({score:5,comment:''})
const fieldMap=computed(()=>new Map((detail.value?.fields||[]).map(field=>[field.field_key,field.field_name])))
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))
function resultText(v:string){return{finish:'正常结束',escalate:'转人工 / 转诊',manual:'人工结束'}[v]||v||'进行中'}
function actionText(v:string){return{start:'开始执行',enter:'进入节点',answer:'提交回答'}[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 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 submitFeedback(){submitting.value=true;try{await api.post(`/runs/${route.params.id}/feedback`,feedback);message.success('反馈已提交');await load()}catch(error){message.error(apiMessage(error))}finally{submitting.value=false}}
onMounted(load)
</script>
<template>
<div class="page-shell run-detail-page">
<a-button type="link" class="back-link" @click="router.push('/runs')"><ArrowLeftOutlined />返回执行记录</a-button>
<a-spin :spinning="loading">
<template v-if="detail">
<header class="detail-heading">
<div><div class="run-code">RUN-{{ String(detail.run.id).padStart(5,'0') }}</div><h1>{{ detail.run.sop_name }}</h1><p>{{ detail.run.scenario_name }} · V{{ detail.run.version }} · {{ detail.run.operator_name }}</p></div>
<div class="result-block" :class="detail.run.result==='escalate'?'warning':'success'"><SafetyCertificateOutlined v-if="detail.run.result==='escalate'"/><CheckCircleOutlined v-else/><span><small>{{ detail.run.status==='completed'?'执行结果':'当前状态' }}</small><b>{{ resultText(detail.run.result) }}</b></span></div>
</header>
<section class="detail-layout">
<main class="surface timeline-panel">
<div class="section-title"><span>执行轨迹</span><small>按实际发生顺序记录</small></div>
<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'">
<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>
<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>
</main>
<aside class="detail-aside">
<section class="surface summary-panel"><div class="section-title"><span>执行信息</span></div><dl><div><dt>状态</dt><dd><a-tag :color="detail.run.status==='completed'?'green':'orange'">{{ detail.run.status==='completed'?'已完成':'进行中' }}</a-tag></dd></div><div><dt>开始时间</dt><dd>{{ new Date(detail.run.started_at).toLocaleString('zh-CN') }}</dd></div><div><dt>完成时间</dt><dd>{{ detail.run.completed_at?new Date(detail.run.completed_at).toLocaleString('zh-CN'):'-' }}</dd></div><div><dt>当前节点</dt><dd>{{ detail.run.current_node_key }}</dd></div></dl></section>
<section class="surface answer-panel"><div class="section-title"><span>已采集信息</span><small>{{ answers.length }} </small></div><div v-if="answers.length" class="answer-list"><div v-for="[key,value] in answers" :key="key"><span>{{ fieldMap.get(key)||key }}</span><b>{{ valueText(value) }}</b></div></div><div v-else class="aside-empty">尚未采集字段</div></section>
</aside>
</section>
<section class="surface feedback-band">
<div class="section-title"><span>执行反馈</span><small>用于判断 SOP 是否清晰有效</small></div>
<div v-if="detail.feedback.length" class="feedback-list"><article v-for="item in detail.feedback" :key="item.id"><div><b>{{ item.user_name }}</b><a-rate :value="item.score" disabled /></div><p>{{ item.comment||'未填写补充说明' }}</p><time>{{ new Date(item.created_at).toLocaleString('zh-CN') }}</time></article></div>
<div v-if="canFeedback" class="feedback-form"><div><span>本次 SOP 是否好用?</span><a-rate v-model:value="feedback.score" /></div><a-textarea v-model:value="feedback.comment" :rows="3" :maxlength="2000" show-count placeholder="记录不顺畅的步骤、缺失信息或有效话术。"/><a-button type="primary" :loading="submitting" @click="submitFeedback">提交反馈</a-button></div>
<div v-if="!detail.feedback.length&&!canFeedback" class="aside-empty">暂无反馈</div>
</section>
</template>
</a-spin>
</div>
</template>
<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}}
</style>

View File

@@ -1,12 +1,40 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { api, apiMessage } from '@/api/client'
import { onMounted, reactive, ref } from 'vue'
import { FilterOutlined, ReloadOutlined, RightOutlined } from '@ant-design/icons-vue'
import { message } from 'ant-design-vue'
import { useRouter } from 'vue-router'
import { api, apiMessage } from '@/api/client'
import type { SOPRun } from '@/types'
const loading=ref(false);const items=ref<SOPRun[]>([])
async function load(){loading.value=true;try{items.value=(await api.get<{items:SOPRun[]}>('/runs')).items}catch(error){message.error(apiMessage(error))}finally{loading.value=false}}
const router=useRouter()
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:''})
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}]
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}}
function statusText(v:string){return v==='completed'?'已完成':'进行中'}
function resultText(v:string){return{finish:'正常结束',escalate:'转人工 / 转诊',manual:'人工结束'}[v]||v||'-'}
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 tableChange(pager:{current?:number;pageSize?:number}){changePage(pager.current||1,pager.pageSize||20)}
onMounted(load)
</script>
<template><div class="page-shell"><div class="page-heading"><div><h1>执行记录</h1><p>查看 SOP 的实际使用情况和执行结果</p></div></div><section class="surface"><a-table row-key="id" :loading="loading" :data-source="items" :pagination="{pageSize:20}" :columns="[{title:'执行编号',dataIndex:'id',width:130},{title:'SOP',dataIndex:'sop_name'},{title:'状态',dataIndex:'status',width:110},{title:'结果',dataIndex:'result',width:120},{title:'开始时间',dataIndex:'started_at',width:190},{title:'完成时间',dataIndex:'completed_at',width:190}]" :scroll="{x:900}"><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'">{{ 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><template #emptyText><div class="empty-copy">还没有执行记录发布 SOP 后即可开始执行</div></template></a-table></section></div></template>
<style scoped>code{padding:2px 5px;color:#17654f;background:#eef5f2;border-radius:3px}</style>
<template>
<div class="page-shell">
<div class="page-heading"><div><h1>执行记录</h1><p>按结果筛选实际执行进入详情复盘每一步回答和分流</p></div></div>
<section class="surface">
<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>
<a-button type="text" aria-label="刷新执行记录" @click="load()"><ReloadOutlined /></a-button>
</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">
<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 #emptyText><div class="empty-copy">当前筛选条件下没有执行记录</div></template>
</a-table>
</section>
</div>
</template>
<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}}
</style>

View File

@@ -4,17 +4,20 @@ import { useRoute, useRouter } from 'vue-router'
import { ArrowDownOutlined, ArrowLeftOutlined, ArrowUpOutlined, DeleteOutlined, PlusOutlined, SaveOutlined, SendOutlined } from '@ant-design/icons-vue'
import { message, Modal } from 'ant-design-vue'
import { api, apiMessage } from '@/api/client'
import { useAuthStore } from '@/stores/auth'
import type { ScenarioField, SOP, SOPEdge, SOPNode, SOPVersion } from '@/types'
interface KnowledgeCard { id:number; scenario_id:number; title:string }
const route = useRoute(); const router = useRouter(); const id = Number(route.params.id)
const auth = useAuthStore()
const loading = ref(true); const saving = ref(false)
const sop = ref<SOP | null>(null); const version = ref<SOPVersion | null>(null)
const nodes = ref<SOPNode[]>([]); const edges = ref<SOPEdge[]>([]); const selectedKey = ref('')
const fields = ref<ScenarioField[]>([]); const knowledgeCards = ref<KnowledgeCard[]>([])
const selected = computed(() => nodes.value.find(n => n.node_key === selectedKey.value))
const editable = computed(() => version.value?.status === 'draft')
const canEdit = computed(() => editable.value&&auth.can('sop.edit'))
const reviewing = computed(() => version.value?.status === 'reviewing')
const nodeTypes = [{value:'message',label:'标准话术'},{value:'question',label:'单项提问'},{value:'form',label:'信息表单'},{value:'choice',label:'选择判断'},{value:'condition',label:'条件节点'},{value:'knowledge',label:'知识卡'},{value:'escalate',label:'转人工/转诊'},{value:'finish',label:'结束'}]
const edgeDraft = reactive({ target_node_key: '', field: '', operator: 'equals', value: '' })
@@ -47,7 +50,7 @@ function conditionText(condition:Record<string,any>):string{if(!condition||Objec
async function save(){saving.value=true;try{await api.put(`/sops/${id}/draft`,{start_node_key:version.value?.start_node_key||'start',nodes:nodes.value.map(({node_key,type,title,content,config,position_x,position_y})=>({node_key,type,title,content,config,position_x,position_y})),edges:edges.value.map(({source_node_key,target_node_key,condition,priority})=>({source_node_key,target_node_key,condition,priority}))});message.success('草稿已保存');await load()}catch(error){message.error(apiMessage(error))}finally{saving.value=false}}
async function validate(){try{const data=await api.post<{valid:boolean;problems:string[]}>(`/sops/${id}/validate`);data.valid?message.success('流程校验通过'):Modal.warning({title:'流程还不能发布',content:data.problems.join('')})}catch(error){message.error(apiMessage(error))}}
async function submitReview(){try{await save();await api.post(`/sops/${id}/submit-review`);message.success('SOP 已提交审核');await load()}catch(error){message.error(apiMessage(error))}}
async function publish(){try{if(editable.value) await save();await api.post(`/sops/${id}/publish`);message.success('SOP 已发布');await load()}catch(error){message.error(apiMessage(error))}}
async function publish(){try{if(canEdit.value) await save();await api.post(`/sops/${id}/publish`);message.success('SOP 已发布');await load()}catch(error){message.error(apiMessage(error))}}
async function offline(){try{await api.post(`/sops/${id}/offline`);message.success('SOP 已下线');await load()}catch(error){message.error(apiMessage(error))}}
async function createVersion(){try{await api.post(`/sops/${id}/versions`);message.success('已创建新草稿版本');await load()}catch(error){message.error(apiMessage(error))}}
onMounted(load)
@@ -57,29 +60,29 @@ onMounted(load)
<div class="editor-page">
<header class="editor-header">
<div class="editor-title"><a-button type="text" aria-label="返回场景" @click="router.push(`/scenarios/${sop?.scenario_id}`)"><ArrowLeftOutlined /></a-button><div><b>{{ sop?.name || 'SOP 编辑器' }}</b><span>版本 {{ version?.version }} · {{ editable ? '草稿' : (reviewing ? '审核中' : (version?.status === 'offline' ? '已下线' : '已发布')) }}</span></div></div>
<div class="page-actions"><a-button @click="validate">校验流程</a-button><a-button v-if="editable" :loading="saving" @click="save"><SaveOutlined />保存</a-button><a-button v-if="editable" @click="submitReview"><SendOutlined />提交审核</a-button><a-button v-if="editable||reviewing" type="primary" @click="publish"><SendOutlined />发布</a-button><a-button v-if="version?.status === 'published'" danger @click="offline">下线</a-button><a-button v-if="!editable&&version?.status !== 'reviewing'&&version?.status !== 'published'" type="primary" @click="createVersion"><PlusOutlined />创建新版本</a-button><a-button v-if="version?.status === 'published'" type="primary" @click="createVersion"><PlusOutlined />创建新版本</a-button></div>
<div class="page-actions"><a-button @click="validate">校验流程</a-button><a-button v-if="canEdit" :loading="saving" @click="save"><SaveOutlined />保存</a-button><a-button v-if="canEdit&&auth.can('sop.submit_review')" @click="submitReview"><SendOutlined />提交审核</a-button><a-button v-if="auth.can('sop.publish')&&(reviewing||(editable&&auth.can('*')))" type="primary" @click="publish"><SendOutlined />发布</a-button><a-button v-if="version?.status === 'published'&&auth.can('sop.publish')" danger @click="offline">下线</a-button><a-button v-if="auth.can('sop.edit')&&!editable&&version?.status !== 'reviewing'" type="primary" @click="createVersion"><PlusOutlined />创建新版本</a-button></div>
</header>
<a-spin :spinning="loading">
<main class="editor-workbench">
<aside class="node-rail surface">
<div class="rail-title"><span>流程节点</span><a-button v-if="editable" type="text" aria-label="添加节点" @click="addNode"><PlusOutlined /></a-button></div>
<div class="rail-title"><span>流程节点</span><a-button v-if="canEdit" type="text" aria-label="添加节点" @click="addNode"><PlusOutlined /></a-button></div>
<div class="node-list">
<button v-for="(node,index) in nodes" :key="node.node_key" :class="{active:selectedKey===node.node_key}" @click="selectedKey=node.node_key"><span class="node-index">{{ String(index+1).padStart(2,'0') }}</span><span class="node-meta"><b>{{ node.title }}</b><small>{{ nodeTypes.find(t=>t.value===node.type)?.label || node.type }}</small></span><span v-if="editable&&node.type!=='start'" class="node-move"><ArrowUpOutlined @click.stop="move(index,-1)"/><ArrowDownOutlined @click.stop="move(index,1)"/></span></button>
<button v-for="(node,index) in nodes" :key="node.node_key" :class="{active:selectedKey===node.node_key}" @click="selectedKey=node.node_key"><span class="node-index">{{ String(index+1).padStart(2,'0') }}</span><span class="node-meta"><b>{{ node.title }}</b><small>{{ nodeTypes.find(t=>t.value===node.type)?.label || node.type }}</small></span><span v-if="canEdit&&node.type!=='start'" class="node-move"><ArrowUpOutlined @click.stop="move(index,-1)"/><ArrowDownOutlined @click.stop="move(index,1)"/></span></button>
</div>
</aside>
<section v-if="selected" class="node-editor surface">
<div class="node-editor-head"><div><span class="node-type-mark">{{ selected.type.toUpperCase() }}</span><h2>{{ selected.title }}</h2></div><a-button v-if="editable&&selected.type!=='start'" danger type="text" @click="removeNode(selected)"><DeleteOutlined />删除</a-button></div>
<div class="node-editor-head"><div><span class="node-type-mark">{{ selected.type.toUpperCase() }}</span><h2>{{ selected.title }}</h2></div><a-button v-if="canEdit&&selected.type!=='start'" danger type="text" @click="removeNode(selected)"><DeleteOutlined />删除</a-button></div>
<a-form layout="vertical" class="property-form">
<div class="property-grid"><a-form-item label="节点名称"><a-input v-model:value="selected.title" :disabled="!editable" /></a-form-item><a-form-item label="节点类型"><a-select v-model:value="selected.type" :disabled="!editable||selected.type==='start'" :options="nodeTypes" /></a-form-item></div>
<a-form-item label="标准话术 / 操作提示"><a-textarea v-model:value="selected.content" :disabled="!editable" :rows="5" placeholder="执行到此节点时展示给一线人员的内容" /></a-form-item>
<template v-if="['question','choice'].includes(selected.type)"><div class="property-grid"><a-form-item label="写入场景字段"><a-select v-model:value="selected.config.field_key" :disabled="!editable" show-search option-filter-prop="label" placeholder="选择需要采集的字段" :options="fields.map(field=>({value:field.field_key,label:`${field.field_name} (${field.field_key})`}))" /></a-form-item><a-form-item label="是否必填"><a-switch v-model:checked="selected.config.required" :disabled="!editable" /></a-form-item></div></template>
<a-form-item v-if="selected.type==='form'" label="表单采集字段"><a-select v-model:value="selected.config.field_keys" mode="multiple" :disabled="!editable" show-search option-filter-prop="label" placeholder="选择本步骤需要采集的字段" :options="fields.map(field=>({value:field.field_key,label:`${field.field_name} (${field.field_key})`}))" /></a-form-item>
<a-form-item v-if="selected.type==='choice'" label="可选项(每行一个)"><a-textarea :value="(selected.config.options||[]).join('\n')" :disabled="!editable" :rows="4" @change="selected.config.options=($event.target as HTMLTextAreaElement).value.split('\n').filter(Boolean)" /></a-form-item>
<a-form-item v-if="selected.type==='knowledge'" label="关联知识卡"><a-select v-model:value="selected.config.knowledge_card_id" :disabled="!editable" placeholder="选择已发布知识卡" :options="knowledgeCards.map(card=>({value:card.id,label:card.title}))" /></a-form-item>
<div class="property-grid"><a-form-item label="节点名称"><a-input v-model:value="selected.title" :disabled="!canEdit" /></a-form-item><a-form-item label="节点类型"><a-select v-model:value="selected.type" :disabled="!canEdit||selected.type==='start'" :options="nodeTypes" /></a-form-item></div>
<a-form-item label="标准话术 / 操作提示"><a-textarea v-model:value="selected.content" :disabled="!canEdit" :rows="5" placeholder="执行到此节点时展示给一线人员的内容" /></a-form-item>
<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==='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>
<div class="transition-head"><div><b>下一步路径</b><span>按优先级匹配默认路径建议放最后</span></div></div>
<div class="transition-list"><div v-for="edge in outgoing(selectedKey)" :key="`${edge.source_node_key}-${edge.target_node_key}-${edge.priority}`" class="transition-row"><span class="route-line"></span><span class="route-target">{{ nodes.find(n=>n.node_key===edge.target_node_key)?.title || edge.target_node_key }}</span><a-tag>{{ conditionText(edge.condition) }}</a-tag><a-button v-if="editable" type="text" danger aria-label="删除路径" @click="removeEdge(edge)"><DeleteOutlined /></a-button></div><div v-if="!outgoing(selectedKey).length" class="route-empty">还没有下一步路径</div></div>
<div v-if="editable&&!['finish','escalate'].includes(selected.type)" class="edge-builder"><a-select v-model:value="edgeDraft.target_node_key" placeholder="目标节点" :options="nodes.filter(n=>n.node_key!==selectedKey).map(n=>({value:n.node_key,label:n.title}))" /><a-input v-model:value="edgeDraft.field" placeholder="条件字段(留空为默认)" /><a-select v-model:value="edgeDraft.operator" :options="[{value:'equals',label:'等于'},{value:'not_equals',label:'不等于'},{value:'contains',label:'包含'},{value:'greater_than',label:'大于'},{value:'less_than',label:'小于'},{value:'exists',label:'已填写'}]" /><a-input v-model:value="edgeDraft.value" placeholder="条件值" /><a-button @click="addEdge"><PlusOutlined />添加路径</a-button></div>
<div class="transition-list"><div v-for="edge in outgoing(selectedKey)" :key="`${edge.source_node_key}-${edge.target_node_key}-${edge.priority}`" class="transition-row"><span class="route-line"></span><span class="route-target">{{ nodes.find(n=>n.node_key===edge.target_node_key)?.title || edge.target_node_key }}</span><a-tag>{{ conditionText(edge.condition) }}</a-tag><a-button v-if="canEdit" type="text" danger aria-label="删除路径" @click="removeEdge(edge)"><DeleteOutlined /></a-button></div><div v-if="!outgoing(selectedKey).length" class="route-empty">还没有下一步路径</div></div>
<div v-if="canEdit&&!['finish','escalate'].includes(selected.type)" class="edge-builder"><a-select v-model:value="edgeDraft.target_node_key" placeholder="目标节点" :options="nodes.filter(n=>n.node_key!==selectedKey).map(n=>({value:n.node_key,label:n.title}))" /><a-input v-model:value="edgeDraft.field" placeholder="条件字段(留空为默认)" /><a-select v-model:value="edgeDraft.operator" :options="[{value:'equals',label:'等于'},{value:'not_equals',label:'不等于'},{value:'contains',label:'包含'},{value:'greater_than',label:'大于'},{value:'less_than',label:'小于'},{value:'exists',label:'已填写'}]" /><a-input v-model:value="edgeDraft.value" placeholder="条件值" /><a-button @click="addEdge"><PlusOutlined />添加路径</a-button></div>
</section>
<a-empty v-else description="请选择一个节点" class="surface editor-empty" />
</main>

View File

@@ -4,10 +4,12 @@ import { useRoute, useRouter } from 'vue-router'
import { ArrowLeftOutlined, BranchesOutlined, DeleteOutlined, PlusOutlined } from '@ant-design/icons-vue'
import { message, Modal } from 'ant-design-vue'
import { api, apiMessage } from '@/api/client'
import { useAuthStore } from '@/stores/auth'
import type { Scenario, ScenarioField, SOP } from '@/types'
const route = useRoute()
const router = useRouter()
const auth = useAuthStore()
const scenarioId = Number(route.params.id)
const loading = ref(true)
const scenario = ref<Scenario | null>(null)
@@ -59,16 +61,16 @@ onMounted(load)
<a-tabs default-active-key="fields" class="detail-tabs">
<a-tab-pane key="fields" tab="场景字段">
<section class="surface">
<div class="toolbar"><div><b>采集字段</b><span class="toolbar-note">执行过程中收集的信息</span></div><a-button type="primary" @click="fieldOpen = true"><PlusOutlined />添加字段</a-button></div>
<div class="toolbar"><div><b>采集字段</b><span class="toolbar-note">执行过程中收集的信息</span></div><a-button v-if="auth.can('scenario.edit')" type="primary" @click="fieldOpen = true"><PlusOutlined />添加字段</a-button></div>
<a-table :data-source="fields" row-key="id" :pagination="false" :columns="[{title:'字段名称',dataIndex:'field_name'},{title:'字段标识',dataIndex:'field_key'},{title:'类型',dataIndex:'field_type',width:130},{title:'必填',dataIndex:'required',width:90},{title:'',key:'action',width:70}]">
<template #bodyCell="{ column, record }"><template v-if="column.dataIndex === 'field_key'"><code>{{ record.field_key }}</code></template><template v-else-if="column.dataIndex === 'required'"><a-tag :color="record.required ? 'orange' : 'default'">{{ record.required ? '必填' : '选填' }}</a-tag></template><template v-else-if="column.key === 'action'"><a-button type="text" danger aria-label="删除字段" @click="deleteField(record)"><DeleteOutlined /></a-button></template></template>
<template #bodyCell="{ column, record }"><template v-if="column.dataIndex === 'field_key'"><code>{{ record.field_key }}</code></template><template v-else-if="column.dataIndex === 'required'"><a-tag :color="record.required ? 'orange' : 'default'">{{ record.required ? '必填' : '选填' }}</a-tag></template><template v-else-if="column.key === 'action'"><a-button v-if="auth.can('scenario.edit')" type="text" danger aria-label="删除字段" @click="deleteField(record)"><DeleteOutlined /></a-button></template></template>
<template #emptyText><div class="empty-copy">还没有字段先添加执行过程中需要收集的客户信息</div></template>
</a-table>
</section>
</a-tab-pane>
<a-tab-pane key="sops" tab="SOP 流程">
<a-tab-pane v-if="auth.can('sop.view')" key="sops" tab="SOP 流程">
<section class="surface">
<div class="toolbar"><div><b>话术流程</b><span class="toolbar-note">一个场景可以包含多套 SOP</span></div><a-button type="primary" @click="sopOpen = true"><PlusOutlined />创建 SOP</a-button></div>
<div class="toolbar"><div><b>话术流程</b><span class="toolbar-note">一个场景可以包含多套 SOP</span></div><a-button v-if="auth.can('sop.edit')" type="primary" @click="sopOpen = true"><PlusOutlined />创建 SOP</a-button></div>
<div v-if="sops.length" class="sop-list">
<button v-for="item in sops" :key="item.id" @click="router.push(`/sops/${item.id}`)"><span class="sop-icon"><BranchesOutlined /></span><span class="sop-copy"><b>{{ item.name }}</b><small>{{ item.description || '暂无说明' }}</small></span><a-tag :color="item.status === 'published' ? 'green' : 'default'">{{ statusText(item.status) }}</a-tag><span class="sop-date">{{ new Date(item.updated_at).toLocaleDateString('zh-CN') }}</span></button>
</div>
@@ -78,7 +80,7 @@ onMounted(load)
</a-tabs>
</a-skeleton>
<a-modal v-model:open="fieldOpen" title="添加场景字段" ok-text="添加字段" @ok="createField">
<a-modal v-if="auth.can('scenario.edit')" v-model:open="fieldOpen" title="添加场景字段" ok-text="添加字段" @ok="createField">
<a-form layout="vertical" :model="fieldForm">
<div class="field-grid"><a-form-item label="字段名称"><a-input v-model:value="fieldForm.field_name" placeholder="宠物体重" /></a-form-item><a-form-item label="字段标识"><a-input v-model:value="fieldForm.field_key" placeholder="pet_weight" /></a-form-item></div>
<a-form-item label="字段类型"><a-select v-model:value="fieldForm.field_type" :options="[{value:'text',label:'单行文本'},{value:'textarea',label:'多行文本'},{value:'number',label:'数字'},{value:'boolean',label:'是/否'},{value:'select',label:'单选'},{value:'multiselect',label:'多选'},{value:'date',label:'日期'}]" /></a-form-item>
@@ -86,7 +88,7 @@ onMounted(load)
<a-checkbox v-model:checked="fieldForm.required">执行时必须填写</a-checkbox>
</a-form>
</a-modal>
<a-modal v-model:open="sopOpen" title="创建 SOP 草稿" ok-text="创建并编排" @ok="createSOP">
<a-modal v-if="auth.can('sop.edit')" v-model:open="sopOpen" title="创建 SOP 草稿" ok-text="创建并编排" @ok="createSOP">
<a-form layout="vertical" :model="sopForm"><a-form-item label="SOP 名称"><a-input v-model:value="sopForm.name" placeholder="问诊问药标准流程" /></a-form-item><a-form-item label="说明"><a-textarea v-model:value="sopForm.description" :rows="4" placeholder="这套流程适用于什么情况" /></a-form-item></a-form>
</a-modal>
</div>

View File

@@ -4,9 +4,11 @@ import { useRouter } from 'vue-router'
import { PlusOutlined, SearchOutlined } from '@ant-design/icons-vue'
import { message } from 'ant-design-vue'
import { api, apiMessage } from '@/api/client'
import { useAuthStore } from '@/stores/auth'
import type { Scenario } from '@/types'
const router = useRouter()
const auth = useAuthStore()
const loading = ref(false)
const open = ref(false)
const keyword = ref('')
@@ -38,7 +40,7 @@ onMounted(load)
<div class="page-shell">
<div class="page-heading">
<div><h1>场景与 SOP</h1><p>从业务触发点开始定义信息字段与可执行话术流程</p></div>
<a-button type="primary" @click="open = true"><PlusOutlined />新建场景</a-button>
<a-button v-if="auth.can('scenario.edit')" type="primary" @click="open = true"><PlusOutlined />新建场景</a-button>
</div>
<section class="surface">
<div class="toolbar">
@@ -50,13 +52,13 @@ onMounted(load)
<template v-if="column.key === 'name'"><div class="scenario-name"><b>{{ record.name }}</b><small>{{ record.goal }}</small></div></template>
<template v-else-if="column.key === 'status'"><a-tag :color="record.status === 'active' ? 'green' : 'default'">{{ statusText(record.status) }}</a-tag></template>
<template v-else-if="column.key === 'updated_at'">{{ new Date(record.updated_at).toLocaleString('zh-CN') }}</template>
<template v-else-if="column.key === 'action'"><a-button type="link" size="small">配置</a-button></template>
<template v-else-if="column.key === 'action'"><a-button type="link" size="small">{{ auth.can('scenario.edit')?'配置':'查看' }}</a-button></template>
</template>
<template #emptyText><div class="empty-copy">还没有业务场景。创建第一个场景后,就可以配置字段和 SOP。</div></template>
</a-table>
</section>
<a-drawer v-model:open="open" title="创建业务场景" width="520" :destroy-on-close="true">
<a-drawer v-if="auth.can('scenario.edit')" v-model:open="open" title="创建业务场景" width="520" :destroy-on-close="true">
<a-form layout="vertical" :model="form">
<a-form-item label="场景名称" name="name" :rules="[{ required: true, message: '请输入场景名称' }]"><a-input v-model:value="form.name" placeholder="例如宠物医生问诊问药" /></a-form-item>
<div class="form-grid"><a-form-item label="所属行业" name="industry" :rules="[{ required: true, message: '请输入行业' }]"><a-input v-model:value="form.industry" placeholder="宠物医疗" /></a-form-item><a-form-item label="适用角色" name="role_name" :rules="[{ required: true, message: '请输入角色' }]"><a-input v-model:value="form.role_name" placeholder="医生客服" /></a-form-item></div>