Version: 0.9.42.dev.260424

后端:
1. 新增课表图片识别接口,支持上传截图后返回“可编辑草稿”(success / partial / reject),并补齐大图、空图、格式不支持、识别能力未配置等错误分支。
2. 课表识别服务接入多模态 Responses 链路,完善图片请求归一化与安全校验(大小、MIME、内容探测),并对识别结果做结构化清洗、强/弱约束校验、告警去重与默认文案兜底。
3. 新增 Ark Responses 统一客户端抽象,支持文本+图片输入、JSON对象输出、usage统计透传与不完整输出识别;同时补齐模型返回 finish_reason 透传,便于定位截断问题。
4. 启动阶段增加课表识图模型与参数注入(模型名、最大图片字节、最大输出token),并将配置示例收敛为“仅保留当前代码实际读取项”。

前端:
5. 课表中心新增“导入课表”完整闭环:上传图片识别、草稿编辑校对、正式导入落库;并新增对应 API 与类型定义。
6. 导入弹窗支持识别中止、全局告警与行级告警展示、低置信度提示、行内编辑、手动新增、删除、拖拽排序、本地校验与提交前二次确认。
7. 正式导入前将草稿按“课程名+地点+是否允许嵌入”聚合为导入结构,并统一携带幂等键请求头,降低重复提交风险。
8. 周课表画板修复跨节次事件遮挡导致的网格错位问题,改进“完全遮挡/部分遮挡”渲染判定与 grid 行定位。
9. 助手流式区域优化“思考中”指示逻辑与样式,避免已有正文时仍展示回答中占位;同时补充全局组件视觉统一(弹窗/按钮)样式。

仓库:
10. 新增课表图片识别前端对接说明文档,补充主动优化能力 PRD 讨论稿,并在协作规范中新增“实现 Eino 新能力前需先查官方文档”的约束。
This commit is contained in:
Losita
2026-04-24 23:33:43 +08:00
parent 8daae62812
commit 04b5836b39
23 changed files with 3539 additions and 171 deletions

View File

@@ -16,6 +16,7 @@
12. 若后续在 `backend/agent` 中新增、下沉、替换任何“通用能力”,必须同步更新 `backend/agent/通用能力接入文档.md`,否则视为重构信息不完整。
13. 写完代码后,如果输入输出格式明确、逻辑可验证(如数据转换函数、解析函数、工具层操作),必须编写单元测试验证正确性。跑完之后删除测试文件(`*_test.go`),禁止把测试文件长期留在项目中。
14. 当 Claude Code 帮助操作 git 提交时commit message 中禁止出现与 Claude 协同相关的描述(如 Co-Authored-By 等),只保留项目本身的内容。
15. 实现任何 Eino 新功能之前,必须先阅读 Eino 官方文档并确认对应能力的推荐接入方式与参数语义,禁止在未查文档的情况下直接编码。
## 注释规范(强制)

View File

@@ -3,6 +3,8 @@ package api
import (
"context"
"errors"
"io"
"log"
"net/http"
"time"
@@ -13,11 +15,9 @@ import (
)
type CourseHandler struct {
// 伸出手:准备接住 Service
service *service.CourseService
}
// NewCourseHandler 创建 CourseHandler 实例
func NewCourseHandler(service *service.CourseService) *CourseHandler {
return &CourseHandler{
service: service,
@@ -25,45 +25,130 @@ func NewCourseHandler(service *service.CourseService) *CourseHandler {
}
func (sa *CourseHandler) CheckUserCourse(c *gin.Context) {
//1.从请求中获取课程信息
var req model.UserCheckCourseRequest
err := c.ShouldBindJSON(&req)
if err != nil {
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, respond.WrongParamType)
return
}
//2.调用 service 层的 CheckSingleCourse 方法进行校验
result := service.CheckSingleCourse(req)
//3.根据校验结果返回响应
if result {
if service.CheckSingleCourse(req) {
c.JSON(http.StatusOK, respond.Ok)
} else {
c.JSON(http.StatusBadRequest, respond.WrongCourseInfo)
return
}
c.JSON(http.StatusBadRequest, respond.WrongCourseInfo)
}
func (sa *CourseHandler) AddUserCourses(c *gin.Context) {
//1.从请求中获取课程信息
var req model.UserImportCoursesRequest
err := c.ShouldBindJSON(&req)
if err != nil {
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, respond.WrongParamType)
return
}
//2.从上下文获取用户ID
userIDInterface := c.GetInt("user_id")
//3.调用 service 层的 AddUserCoursesIntoSchedule 方法添加课程
// 创建一个带 1 秒超时的上下文
userID := c.GetInt("user_id")
ctx, cancel := context.WithTimeout(c.Request.Context(), 10*time.Second)
defer cancel() // 记得释放资源
conflicts, err := sa.service.AddUserCourses(ctx, req, userIDInterface)
defer cancel()
conflicts, err := sa.service.AddUserCourses(ctx, req, userID)
if err != nil {
if errors.Is(err, respond.ScheduleConflict) {
c.JSON(http.StatusConflict, respond.RespWithData(respond.ScheduleConflict, conflicts))
return
}
respond.DealWithError(c, err)
return
}
//4.返回成功响应
c.JSON(http.StatusOK, respond.Ok)
}
func (sa *CourseHandler) ParseCourseTableImage(c *gin.Context) {
userID := c.GetInt("user_id")
fileHeader, err := c.FormFile("image")
if err != nil {
log.Printf("[COURSE_PARSE][API] missing file user=%d path=%s err=%v", userID, c.FullPath(), err)
c.JSON(http.StatusBadRequest, respond.MissingParam)
return
}
log.Printf(
"[COURSE_PARSE][API] request start user=%d path=%s filename=%q header_content_type=%q size=%d",
userID,
c.FullPath(),
fileHeader.Filename,
fileHeader.Header.Get("Content-Type"),
fileHeader.Size,
)
file, err := fileHeader.Open()
if err != nil {
log.Printf("[COURSE_PARSE][API] open file failed user=%d filename=%q err=%v", userID, fileHeader.Filename, err)
c.JSON(http.StatusBadRequest, respond.WrongParamType)
return
}
defer file.Close()
imageBytes, err := io.ReadAll(file)
if err != nil {
log.Printf("[COURSE_PARSE][API] read file failed user=%d filename=%q err=%v", userID, fileHeader.Filename, err)
respond.DealWithError(c, err)
return
}
log.Printf(
"[COURSE_PARSE][API] file loaded user=%d filename=%q bytes=%d",
userID,
fileHeader.Filename,
len(imageBytes),
)
// 课表图片识别当前不再额外叠加服务端 45 秒超时,避免长耗时多模态请求被本层提前打断。
// 这里只跟随客户端请求上下文,便于先观察真实上游耗时与失败位置。
ctx, cancel := context.WithCancel(c.Request.Context())
defer cancel()
draft, err := sa.service.ParseCourseTableImage(ctx, model.CourseImageParseRequest{
Filename: fileHeader.Filename,
MIMEType: fileHeader.Header.Get("Content-Type"),
ImageBytes: imageBytes,
})
if err != nil {
switch {
case errors.Is(err, service.ErrCourseImageParserUnavailable):
log.Printf("[COURSE_PARSE][API] parser unavailable user=%d filename=%q", userID, fileHeader.Filename)
c.JSON(http.StatusServiceUnavailable, respond.Response{Status: "50003", Info: "course image parser is not configured"})
return
case errors.Is(err, service.ErrCourseImageTooLarge):
log.Printf("[COURSE_PARSE][API] file too large user=%d filename=%q bytes=%d", userID, fileHeader.Filename, len(imageBytes))
c.JSON(http.StatusBadRequest, respond.Response{Status: "40064", Info: "course image too large"})
return
case errors.Is(err, service.ErrCourseImageUnsupportedMIME):
log.Printf(
"[COURSE_PARSE][API] unsupported mime user=%d filename=%q header_content_type=%q",
userID,
fileHeader.Filename,
fileHeader.Header.Get("Content-Type"),
)
c.JSON(http.StatusBadRequest, respond.Response{Status: "40065", Info: "unsupported course image format"})
return
case errors.Is(err, service.ErrCourseImageEmpty):
log.Printf("[COURSE_PARSE][API] empty file user=%d filename=%q", userID, fileHeader.Filename)
c.JSON(http.StatusBadRequest, respond.Response{Status: "40066", Info: "course image is empty"})
return
default:
log.Printf("[COURSE_PARSE][API] unexpected failure user=%d filename=%q err=%v", userID, fileHeader.Filename, err)
respond.DealWithError(c, err)
return
}
}
log.Printf(
"[COURSE_PARSE][API] request success user=%d filename=%q draft_status=%s rows=%d warnings=%d",
userID,
fileHeader.Filename,
draft.DraftStatus,
len(draft.Rows),
len(draft.Warnings),
)
c.JSON(http.StatusOK, respond.RespWithData(respond.Ok, draft))
}

View File

@@ -4,6 +4,7 @@ import (
"context"
"fmt"
"log"
"os"
"time"
"github.com/LoveLosita/smartflow/backend/api"
@@ -148,7 +149,21 @@ func Start() {
// Service 层初始化。
userService := service.NewUserService(userRepo, cacheRepo)
taskSv := service.NewTaskService(taskRepo, cacheRepo, eventBus)
courseService := service.NewCourseService(courseRepo, scheduleRepo)
courseImageResponsesClient := infrallm.NewArkResponsesClient(
os.Getenv("ARK_API_KEY"),
viper.GetString("agent.baseURL"),
viper.GetString("courseImport.visionModel"),
)
courseService := service.NewCourseService(
courseRepo,
scheduleRepo,
courseImageResponsesClient,
service.NewCourseImageParseConfig(
viper.GetInt64("courseImport.maxImageBytes"),
viper.GetInt("courseImport.maxTokens"),
),
viper.GetString("courseImport.visionModel"),
)
taskClassService := service.NewTaskClassService(taskClassRepo, cacheRepo, scheduleRepo, manager)
scheduleService := service.NewScheduleService(scheduleRepo, userRepo, taskClassRepo, manager, cacheRepo)
agentService := service.NewAgentServiceWithSchedule(aiHub, agentRepo, taskRepo, cacheRepo, agentCacheRepo, eventBus, scheduleService, taskSv)

View File

@@ -1,18 +1,12 @@
# SmartFlow 后端配置示例
#
# 使用说明:
# 1. 请复制为 config.yaml 后按实际环境填写。
# 2. 示例文件强调“结构清晰”和“字段语义明确”,不是生产推荐值
# 3. 若你只想看 memory 相关配置,优先看本文件下半部分的 memory / rag / websearch 段。
# 说明:
# 1. 请复制为 config.yaml 后按实际环境填写。
# 2. 示例只保留当前代码仍会读取的配置项,避免示例与运行配置持续漂移
# 服务启动与 HTTP 行为
# 服务启动配置
server:
# HTTP 监听端口。
port: 8080
# gin 运行模式debug / release。
mode: debug
# 单次请求默认超时时间。
timeout: 30s
# MySQL 主库配置。
database:
@@ -21,30 +15,19 @@ database:
user: smartflow_user
password: "put_your_database_password_here"
dbname: "put_your_database_name_here"
charset: utf8mb4
parseTime: true
loc: Local
# 登录态与鉴权令牌配置。
jwt:
accessSecret: "put_your_jwt_access_secret_here"
refreshSecret: "put_your_jwt_refresh_secret_here"
# access token 有效期,面向接口鉴权。
accessTokenExpire: 15min
# refresh token 有效期,面向续签。
refreshTokenExpire: 7d
# 应用日志输出配置。
log:
level: info
path: logs/
# Redis 缓存与轻量状态存储。
# Redis 配置。
redis:
host: localhost
port: 6379
password: ""
db: 0
# Kafka outbox 事件总线配置。
kafka:
@@ -60,156 +43,91 @@ kafka:
# 时间与学期边界配置。
time:
zone: "Asia/Shanghai"
# 学期开始日期,一定要设定为周一,以便于计算周数。
semesterStartDate: "2026-03-02"
# 学期结束日期,一定要设定为周日,确保最后一周完整。
semesterEndDate: "2026-07-19"
# 智能体模型与规划参数
# 智能体模型配置
agent:
# 轻量模型:标题生成等低复杂度、低延迟场景。
liteModel: "doubao-seed-2-0-code-preview-260215"
# 标准模型Chat 路由/闲聊/深度回答/Deliver 总结。
proModel: "doubao-seed-2-0-code-preview-260215"
# 高能力模型Plan 规划 + Execute ReAct 等深度推理场景。
maxModel: "doubao-seed-2-0-code-preview-260215"
# 模型服务根路径。
baseURL: "https://ark.cn-beijing.volces.com/api/v3"
# 日内并发优化并发度,建议按模型配额调整。
dailyRefineConcurrency: 7
# 周级跨天配平额度上限,防止过度调整。
weeklyAdjustBudget: 5
thinking:
# plan 节点(单轮深度规划),默认开 thinking。
plan: true
# execute 节点ReAct 深度推理),默认开 thinking。
execute: true
# deliver 节点(交付总结),默认关 thinking。
deliver: false
# 记忆模块(决策比对 + 抽取),默认关 thinking。
memory: false
# 课表图片导入识别配置。
courseImport:
visionModel: ""
maxImageBytes: 5242880
maxTokens: 8192
# 通用 RAG 配置。
rag:
# 总开关;关闭后不再走通用向量检索链路。
enabled: true
# 当前向量存储类型可选inmemory / milvus。
store: "milvus"
# 召回候选上限。
topK: 8
# 召回相似度阈值。
threshold: 0.55
retrieve:
# 单次检索超时时间,避免主链路长时间阻塞。
timeoutMs: 1500
ingest:
# 文档切块大小;过大影响召回精度,过小影响上下文完整度。
chunkSize: 400
# 相邻 chunk 重叠字符数。
chunkOverlap: 80
embed:
# embedding 供应商实现可选mock / eino。
provider: "eino"
# embedding 模型名。
model: "doubao-embedding-vision-251215"
# embedding 服务根路径API Key 统一从环境变量读取。
baseURL: "https://ark.cn-beijing.volces.com/api/v3"
timeoutMs: 1200
# 向量维度,必须与向量库 collection 配置一致。
dimension: 1024
reranker:
# 是否启用重排。
enabled: false
# 当前默认 noop后续可扩展。
provider: "noop"
timeoutMs: 1200
milvus:
# Milvus REST 地址,不要填健康检查口。
address: "http://localhost:19530"
token: "root:Milvus"
dbName: ""
# 通用 RAG chunk collection。
collectionName: "smartflow_rag_chunks"
metricType: "COSINE"
requestTimeoutMs: 1500
# 记忆模块配置。
memory:
# memory 总开关;关闭后不做抽取、写入、召回、注入。
enabled: true
rag:
# 是否允许 memory 读写链路使用向量召回能力。
# 关闭后memory 里的“语义候选”会退回 MySQL 路径,不等于整个 memory 模块关闭。
enabled: true
read:
# 读取模式:
# 1. legacy旧读链路语义上是“RAG 优先,失败再走 legacy”。
# 2. hybrid新读链路先取强约束再补语义候选再统一去重/排序/预算裁剪。
# 3. 如果你想强制纯 MySQL 召回,建议同时设置 read.mode=legacy 且 memory.rag.enabled=false。
mode: legacy
# constraint 类型最大注入条数。
constraintLimit: 5
# preference 类型最大注入条数。
preferenceLimit: 5
# fact 类型最大注入条数。
factLimit: 5
inject:
# 注入渲染模式:
# flat 为旧扁平列表typed_v2 为按类型分段,便于模型区分“硬约束”和“参考事实”。
renderMode: flat
prompt:
# 留空表示走代码内默认抽取 prompt。
extract: ""
# 留空表示走代码内默认决策 prompt。
decision: ""
# memory 向量召回阈值。
threshold: 0.55
# 是否启用重排;当前默认关闭。
enableReranker: false
llm:
# 记忆抽取/决策使用的 LLM 随机度,默认尽量保守,提升可复现性。
temperature: 0.1
topP: 0.2
job:
# 异步记忆任务最大重试次数。
maxRetry: 6
worker:
# worker 轮询间隔。
pollEvery: 2s
# 单次认领任务数。
claimBatch: 1
decision:
# 决策层总开关。
# 开启后,写入链路会从”直接新增”升级成”召回旧记忆 -> 比对 -> 决策动作”。
enabled: true
# 决策层语义候选数上限。
candidateTopK: 5
# 决策层语义候选最低相似度阈值。
candidateMinScore: 0.6
# 决策流程整体失败时的降级策略:
# legacy_add退回旧路径直接新增
# drop直接丢弃本次写入
fallbackMode: legacy_add
write:
# 写入模式:
# legacy沿用旧写入路径
# decision启用决策式写入
# 注意:只有 decision.enabled=true 时,这个值才真正生效。
mode: legacy
# 写入最低置信度阈值,抽取结果 confidence 低于此值直接丢弃。
minConfidence: 0.5
# 联网搜索能力配置。
websearch:
# 可选mock | bocha。
provider: bocha
# 搜索供应商 API Keybocha 模式必填,否则会降级为 mock。
apiKey: ""
# 单次搜索请求超时。
timeout: 10s
# 单次 URL 抓取超时。
fetchTimeout: 15s
# 抓取正文最大字符数。
fetchMaxChars: 4000
rag:
# 是否把 websearch 结果继续送入 RAG 处理。
enabled: false

View File

@@ -32,7 +32,19 @@ func WrapArkClient(arkChatModel *ark.ChatModel) *Client {
if msg == nil {
return nil, errors.New("ark model returned nil message")
}
return &TextResult{Text: msg.Content}, nil
var usage *schema.TokenUsage
finishReason := ""
if msg.ResponseMeta != nil {
usage = CloneUsage(msg.ResponseMeta.Usage)
finishReason = msg.ResponseMeta.FinishReason
}
return &TextResult{
Text: msg.Content,
Usage: usage,
FinishReason: finishReason,
}, nil
}
// 流式文本生成。

View File

@@ -0,0 +1,337 @@
package llm
import (
"context"
"errors"
"fmt"
"strings"
"github.com/volcengine/volcengine-go-sdk/service/arkruntime"
"github.com/volcengine/volcengine-go-sdk/service/arkruntime/model/responses"
)
// ArkResponsesMessage 描述一次 Responses 输入消息。
//
// 职责边界:
// 1. 负责表达角色与多模态内容(文本/图片);
// 2. 不负责业务 prompt 生成;
// 3. 不负责输出 JSON 的字段校验。
type ArkResponsesMessage struct {
Role string
Text string
ImageURL string
ImageDetail string
}
// ArkResponsesOptions 描述 Responses 生成选项。
type ArkResponsesOptions struct {
Model string
Temperature float64
MaxOutputTokens int
Thinking ThinkingMode
TextFormat string
}
// ArkResponsesUsage 统一透传 token 使用量。
type ArkResponsesUsage struct {
InputTokens int64
OutputTokens int64
TotalTokens int64
}
// ArkResponsesResult 是 Ark Responses 的统一输出结构。
type ArkResponsesResult struct {
Text string
Status string
IncompleteReason string
ErrorCode string
ErrorMessage string
Usage *ArkResponsesUsage
}
// ArkResponsesClient 是 Ark SDK Responses 的统一模型出口。
type ArkResponsesClient struct {
model string
client *arkruntime.Client
}
// NewArkResponsesClient 创建 Ark SDK Responses 客户端。
//
// 说明:
// 1. model 为空时返回 nil表示当前能力未启用
// 2. baseURL 为空时使用 SDK 默认地址;
// 3. 仅负责客户端创建,不做连通性探测。
func NewArkResponsesClient(apiKey string, baseURL string, model string) *ArkResponsesClient {
model = strings.TrimSpace(model)
if model == "" {
return nil
}
options := make([]arkruntime.ConfigOption, 0, 1)
if strings.TrimSpace(baseURL) != "" {
options = append(options, arkruntime.WithBaseUrl(strings.TrimSpace(baseURL)))
}
return &ArkResponsesClient{
model: model,
client: arkruntime.NewClientWithApiKey(strings.TrimSpace(apiKey), options...),
}
}
// GenerateText 执行一次非流式 Responses 调用并提取文本。
func (c *ArkResponsesClient) GenerateText(ctx context.Context, messages []ArkResponsesMessage, options ArkResponsesOptions) (*ArkResponsesResult, error) {
req, err := c.buildRequest(messages, options)
if err != nil {
return nil, err
}
resp, err := c.client.CreateResponses(ctx, req)
if err != nil {
return nil, err
}
result := buildArkResponsesResult(resp)
if result.Status == "failed" {
if result.ErrorMessage != "" {
return result, fmt.Errorf("ark responses failed: %s", result.ErrorMessage)
}
return result, errors.New("ark responses failed")
}
if strings.TrimSpace(result.Text) == "" {
return result, FormatEmptyResponseError("ark_responses")
}
return result, nil
}
// GenerateArkResponsesJSON 先调用 Responses再解析为 JSON 结构体。
func GenerateArkResponsesJSON[T any](ctx context.Context, client *ArkResponsesClient, messages []ArkResponsesMessage, options ArkResponsesOptions) (*T, *ArkResponsesResult, error) {
if client == nil {
return nil, nil, errors.New("ark responses client is not ready")
}
result, err := client.GenerateText(ctx, messages, options)
if err != nil {
return nil, result, err
}
parsed, err := ParseJSONObject[T](result.Text)
if err != nil {
return nil, result, err
}
return parsed, result, nil
}
func (c *ArkResponsesClient) buildRequest(messages []ArkResponsesMessage, options ArkResponsesOptions) (*responses.ResponsesRequest, error) {
if c == nil || c.client == nil {
return nil, errors.New("ark responses client is not ready")
}
if len(messages) == 0 {
return nil, errors.New("ark responses messages is empty")
}
modelName := strings.TrimSpace(options.Model)
if modelName == "" {
modelName = c.model
}
if modelName == "" {
return nil, errors.New("ark responses model is empty")
}
inputItems := make([]*responses.InputItem, 0, len(messages))
for idx := range messages {
item, err := buildInputItem(messages[idx])
if err != nil {
return nil, fmt.Errorf("build ark responses message[%d] failed: %w", idx, err)
}
inputItems = append(inputItems, item)
}
request := &responses.ResponsesRequest{
Model: modelName,
Input: &responses.ResponsesInput{
Union: &responses.ResponsesInput_ListValue{
ListValue: &responses.InputItemList{ListValue: inputItems},
},
},
}
if options.Temperature > 0 {
request.Temperature = float64Ptr(options.Temperature)
}
if options.MaxOutputTokens > 0 {
request.MaxOutputTokens = int64Ptr(int64(options.MaxOutputTokens))
}
switch options.Thinking {
case ThinkingModeEnabled:
thinkingType := responses.ThinkingType_enabled
request.Thinking = &responses.ResponsesThinking{Type: &thinkingType}
case ThinkingModeDisabled:
thinkingType := responses.ThinkingType_disabled
request.Thinking = &responses.ResponsesThinking{Type: &thinkingType}
}
if textType, ok := parseTextType(options.TextFormat); ok {
request.Text = &responses.ResponsesText{
Format: &responses.TextFormat{
Type: textType,
},
}
}
return request, nil
}
func buildInputItem(message ArkResponsesMessage) (*responses.InputItem, error) {
role, ok := parseMessageRole(message.Role)
if !ok {
return nil, fmt.Errorf("unsupported message role: %s", strings.TrimSpace(message.Role))
}
content := make([]*responses.ContentItem, 0, 2)
if text := strings.TrimSpace(message.Text); text != "" {
content = append(content, &responses.ContentItem{
Union: &responses.ContentItem_Text{
Text: &responses.ContentItemText{
Type: responses.ContentItemType_input_text,
Text: text,
},
},
})
}
if imageURL := strings.TrimSpace(message.ImageURL); imageURL != "" {
image := &responses.ContentItemImage{
Type: responses.ContentItemType_input_image,
ImageUrl: stringPtr(imageURL),
}
if detail, ok := parseImageDetail(message.ImageDetail); ok {
image.Detail = &detail
}
content = append(content, &responses.ContentItem{
Union: &responses.ContentItem_Image{
Image: image,
},
})
}
if len(content) == 0 {
return nil, errors.New("message content is empty")
}
return &responses.InputItem{
Union: &responses.InputItem_InputMessage{
InputMessage: &responses.ItemInputMessage{
Role: role,
Content: content,
},
},
}, nil
}
func buildArkResponsesResult(resp *responses.ResponseObject) *ArkResponsesResult {
if resp == nil {
return &ArkResponsesResult{}
}
result := &ArkResponsesResult{
Text: extractArkResponsesText(resp),
Status: strings.TrimSpace(resp.GetStatus().String()),
}
if details := resp.GetIncompleteDetails(); details != nil {
result.IncompleteReason = strings.TrimSpace(details.GetReason())
}
if responseErr := resp.GetError(); responseErr != nil {
result.ErrorCode = strings.TrimSpace(responseErr.GetCode())
result.ErrorMessage = strings.TrimSpace(responseErr.GetMessage())
}
if usage := resp.GetUsage(); usage != nil {
result.Usage = &ArkResponsesUsage{
InputTokens: usage.GetInputTokens(),
OutputTokens: usage.GetOutputTokens(),
TotalTokens: usage.GetTotalTokens(),
}
}
return result
}
func extractArkResponsesText(resp *responses.ResponseObject) string {
if resp == nil {
return ""
}
textParts := make([]string, 0, 2)
for _, outputItem := range resp.GetOutput() {
outputMessage := outputItem.GetOutputMessage()
if outputMessage == nil {
continue
}
for _, contentItem := range outputMessage.GetContent() {
text := strings.TrimSpace(contentItem.GetText().GetText())
if text == "" {
continue
}
textParts = append(textParts, text)
}
}
return strings.TrimSpace(strings.Join(textParts, "\n"))
}
func parseMessageRole(raw string) (responses.MessageRole_Enum, bool) {
switch strings.ToLower(strings.TrimSpace(raw)) {
case "user":
return responses.MessageRole_user, true
case "system":
return responses.MessageRole_system, true
case "developer":
return responses.MessageRole_developer, true
case "assistant":
return responses.MessageRole_assistant, true
default:
return responses.MessageRole_unspecified, false
}
}
func parseImageDetail(raw string) (responses.ContentItemImageDetail_Enum, bool) {
switch strings.ToLower(strings.TrimSpace(raw)) {
case "high":
return responses.ContentItemImageDetail_high, true
case "low":
return responses.ContentItemImageDetail_low, true
case "auto":
return responses.ContentItemImageDetail_auto, true
default:
return responses.ContentItemImageDetail_auto, false
}
}
func parseTextType(raw string) (responses.TextType_Enum, bool) {
switch strings.ToLower(strings.TrimSpace(raw)) {
case "":
return responses.TextType_unspecified, false
case "text":
return responses.TextType_text, true
case "json_object":
return responses.TextType_json_object, true
default:
return responses.TextType_unspecified, false
}
}
func stringPtr(value string) *string {
return &value
}
func float64Ptr(value float64) *float64 {
return &value
}
func int64Ptr(value int64) *int64 {
return &value
}

View File

@@ -45,6 +45,8 @@ type GenerateOptions struct {
type TextResult struct {
Text string
Usage *schema.TokenUsage
// FinishReason 透传 provider 的停止原因,便于上层判断是否因 length 等原因被截断。
FinishReason string
}
// StreamReader 抽象了“可逐块 Recv 的流式返回器”。

View File

@@ -8,16 +8,10 @@ import (
"github.com/spf13/viper"
)
// AIHub 存储三级模型的实例,按能力分级调度。
//
// 分级策略:
// 1. Lite轻量模型用于标题生成等低复杂度、低延迟场景
// 2. Pro标准模型用于 Chat 路由/闲聊/深度回答/Deliver 总结;
// 3. Max高能力模型用于 Plan 规划和 Execute ReAct 等需要深度推理的场景。
type AIHub struct {
Lite *ark.ChatModel // 轻量模型:标题生成等低复杂度任务
Pro *ark.ChatModel // 标准模型Chat 路由、闲聊、交付总结
Max *ark.ChatModel // 高能力模型Plan 规划、Execute ReAct
Lite *ark.ChatModel
Pro *ark.ChatModel
Max *ark.ChatModel
}
func InitEino() (*AIHub, error) {
@@ -25,7 +19,6 @@ func InitEino() (*AIHub, error) {
baseURL := viper.GetString("agent.baseURL")
apiKey := os.Getenv("ARK_API_KEY")
// 1. Lite 模型:标题生成等低复杂度场景,优先控制成本和延迟。
lite, err := ark.NewChatModel(ctx, &ark.ChatModelConfig{
Model: viper.GetString("agent.liteModel"),
BaseURL: baseURL,
@@ -34,7 +27,7 @@ func InitEino() (*AIHub, error) {
if err != nil {
return nil, err
}
// 2. Pro 模型Chat 路由/闲聊/交付总结等标准复杂度场景。
pro, err := ark.NewChatModel(ctx, &ark.ChatModelConfig{
Model: viper.GetString("agent.proModel"),
BaseURL: baseURL,
@@ -43,7 +36,7 @@ func InitEino() (*AIHub, error) {
if err != nil {
return nil, err
}
// 3. Max 模型Plan 规划和 Execute ReAct 等需要深度推理的场景。
maxModel, err := ark.NewChatModel(ctx, &ark.ChatModelConfig{
Model: viper.GetString("agent.maxModel"),
BaseURL: baseURL,
@@ -52,6 +45,7 @@ func InitEino() (*AIHub, error) {
if err != nil {
return nil, err
}
return &AIHub{
Lite: lite,
Pro: pro,

View File

@@ -0,0 +1,38 @@
package model
type CourseImageParseDraftStatus string
const (
CourseImageParseDraftStatusSuccess CourseImageParseDraftStatus = "success"
CourseImageParseDraftStatusPartial CourseImageParseDraftStatus = "partial"
CourseImageParseDraftStatusReject CourseImageParseDraftStatus = "reject"
)
type CourseImageParseRow struct {
RowID string `json:"row_id"`
CourseName string `json:"course_name"`
Location string `json:"location"`
IsAllowTasks bool `json:"is_allow_tasks"`
StartWeek *int `json:"start_week"`
EndWeek *int `json:"end_week"`
DayOfWeek *int `json:"day_of_week"`
StartSection *int `json:"start_section"`
EndSection *int `json:"end_section"`
WeekType string `json:"week_type"`
Confidence float64 `json:"confidence"`
RawText string `json:"raw_text"`
RowWarnings []string `json:"row_warnings"`
}
type CourseImageParseResponse struct {
DraftStatus CourseImageParseDraftStatus `json:"draft_status"`
Message string `json:"message"`
Warnings []string `json:"warnings"`
Rows []CourseImageParseRow `json:"rows"`
}
type CourseImageParseRequest struct {
Filename string
MIMEType string
ImageBytes []byte
}

View File

@@ -0,0 +1,732 @@
# SmartFlow 主动优化功能 PRD讨论版
## 0. 文档信息
- 文档状态:讨论中(骨架版)
- 适用范围:主动优化(对话内 execute + 对话内任务类共创)
- 文档目的:先对齐产品方向,再指导后续实现
- 约束说明:本 PRD 只谈产品,不谈技术实现
---
## 1. 业务背景与问题定义(已讨论 v0.1
### 1.1 当前用户问题
- 用户并不总会明确表达需求,存在两类典型入口:
- 默认入口:用户未明确偏好,只希望“尽快排好任务类”。
- 偏好入口:用户给出较多约束与倾向(强度、时段、节奏、容错等)。
- 现状容易把优化做成“单点最佳实践”或“一次性建议”,缺少可持续迭代与偏好对齐。
- 因此,工具体系必须同时支持:
- 在信息不足时,按科学界公认最佳实践给出稳健中位方案。
- 在用户偏好明确时,优先按用户需求调参,不盲从默认最佳实践。
### 1.2 核心问题陈述
- 我们要解决的问题是:
`如何让 AI 在“科学最佳实践”和“用户个性化需求”之间做可解释、可调节、可收敛的主动优化。`
- 该问题直接决定工具设计方向:
- 读工具覆盖面必须足够广,能够支撑不同偏好下的判断。
- 每个核心指标必须是“区间型”而不是“单点型”:
- 默认站在中位(平衡值)。
- 能向左/向右偏移,对应不同用户诉求。
### 1.3 本章已确定结论
- 首发主用户策略:
- 若用户需求不提或较弱,系统默认采用中位最佳实践快速生成。
- 若用户需求明确且较多,系统优先满足用户需求,科学原则作为安全边界。
- “满意方案”判定口径(本章层面):
- 本质不是固定模板,而是“在用户诉求方向上的可接受平衡点”。
- 默认用户采用中位平衡;偏好用户采用定向偏移平衡。
- 自动优化容忍边界(当前已定项):
- 轮次上限暂定 60 轮。
- 时长与是否开启深度思考的权衡暂不在本章冻结,后续章节决策。
### 1.4 对后续章节的约束
- 第 6 章(科学原则)必须给出“中位默认 + 双向偏移”的可解释规则。
- 第 8 章(工具蓝图)必须体现“覆盖广度 + 区间刻度”的产品能力。
- 第 11 章(指标验收)必须衡量“默认模式质量”与“偏好对齐质量”两条线。
---
## 2. 产品目标与非目标(已讨论 v0.1
### 2.1 产品目标定义与优先级(已定)
- 目标 A最高优先级自主迭代收敛
- 定义AI 以“观测-调整-复盘”循环持续优化,直到达到可接受方案再收口。
- 用户价值:减少用户逐步指挥成本,体现“主动出击”。
- 目标 B第二优先级可解释且有改进证据
- 定义:每轮调整都要给出“为何调整、调整内容、前后差异”。
- 用户价值:可控、可信,避免“黑箱瞎调”。
- 目标 C第三优先级对话内任务类共创草案
- 定义用户在聊天中触发后AI 通过反问与检索产出完整任务类草案。
- 用户价值:降低冷启动门槛,减少配置负担,避免新增第二交互区。
- 优先级结论:`A > B > C`
### 2.2 阶段目标策略(已定)
- 首发必须保证A 与 B 构成闭环能力。
- 首发可落可迭代C 以“可用版”上线,后续逐步提高草案准确率与覆盖深度。
- 取舍原则:若资源冲突,优先保障 A若 A 满足基本可用,再保障 BC 按剩余资源推进。
### 2.3 非目标(已定)
- 不追求一次优化即全局最优,目标是“可收敛的高质量可接受方案”。
- 不追求首发覆盖全部学习风格与全部人群偏好。
- 不追求在高风险场景下完全替代用户决策。
- 不以“工具数量”作为目标,避免能力堆叠但无法形成闭环价值。
### 2.4 本章已确定结论
- 我们的核心差异化能力是 A主动迭代优化不是一次性建议或单轮算法执行。
- B 是 A 的信任保障,必须同步建设,不能后补。
- C 是重要入口能力,但在首发阶段不应挤占 A/B 的闭环建设资源。
### 2.5 对后续章节的约束
- 第 5 章(主动优化流程)必须完整体现 A 的循环收敛机制。
- 第 9 章(交互要求)必须体现 B 的解释与改进证据结构。
- 第 12 章(分期路线图)必须以 `A > B > C` 排序规划交付。
---
## 3. 用户与场景(已讨论 v1.0
### 3.1 目标用户分层(已形成草案)
| 用户分层 | 典型特征 | 当前痛点 | 价值诉求 | 首发优先级 |
|---|---|---|---|---|
| 极速排程型 | 不想多聊,希望尽快出方案 | 参数配置成本高、上手慢 | 一键可用、少改动 | P1 |
| 偏好驱动型 | 明确表达强度/时段/节奏偏好 | 通用最佳实践不一定贴合个人需求 | 结果沿偏好方向明显偏移、可控可解释 | P0首发主优先 |
| 反复调优型 | 接受多轮优化,关注持续变好 | 容易遇到来回调整、无效微调 | 稳定收敛、每轮有改进证据 | P1 |
### 3.2 首发核心场景清单(已形成草案)
| 场景 | 触发方式 | 用户期望 | 成功标准 |
|---|---|---|---|
| 场景 S1对话内任务类共创草案 | 用户在聊天中提出“帮我设计任务类” | 快速得到完整且可确认的任务类草案 | 用户可直接采纳或仅小幅修改后采纳 |
| 场景 S2对话内“帮我优化一下” | 用户在对话中发起优化请求 | AI 主动多轮调整并收口 | 至少完成 1-2 轮有效改进且最终可交付 |
| 场景 S3对话内“按我的偏好重排” | 用户明确给出偏好/约束 | AI 优先满足偏好,不盲从默认最佳实践 | 结果明显朝偏好方向偏移且不破坏硬约束 |
### 3.3 场景优先策略(已形成草案)
- 首发优先主线偏好驱动型P0
- 原因:该人群最能体现本功能差异化价值,即“可调节的主动优化”,而非一次性默认排程。
- 策略要求:所有首发核心场景都必须支持“默认中位 + 偏好偏移”双模式。
### 3.4 暂不支持场景清单(草案)
| 暂不支持场景 | 暂缓原因 | 后续进入条件 |
|---|---|---|
| 跨超长周期(如整学期/跨学期)全局最优规划 | 目标跨度过大,首发优先保证局部收敛质量 | 收敛稳定性和性能目标达标后再纳入 |
| 多主体联合排程(多人协同/冲突协商) | 交互复杂度高,超出首发边界 | 单人场景成熟后评估 |
| 高风险不可逆决策自动执行 | 需要更强确认链路与责任边界 | 风险治理机制完善后评估 |
### 3.5 本章已确定的判定阈值口径
- S1任务类共创草案“小幅修改”阈值
- 定义:关键字段修改率 <= 30% 视为“小幅修改”。
- 用途:衡量草案可用性与采纳质量(用于产品验收,不作为用户前台提示)。
- S2主动优化“有效改进”最小标准
- 定义:至少一个核心问题域的严重度下降,视为“有效改进”。
- 严重度层级:`critical > warning > info`
- 用途:判断单轮优化是否有实质收益,避免无效循环。
- S3偏好冲突裁决规则
- 定义:用户偏好优先,科学原则兜底。
- 用途:在“通用最佳实践 vs 用户个性化需求”冲突时,给出统一裁决路径。
### 3.6 新增场景候选对话内任务类共创WebSearch 增强)
#### 3.6.1 场景定义(已讨论结论)
- 场景目标:由 AI 在对话中产出“完整任务类草案”,而非仅补全单个参数。
- 触发方式:仅支持聊天触发,不新增聊天外按钮入口。
- 原因:该能力需要多轮反问与澄清,若放在聊天外容易形成“第二对话区”,增加认知负担。
#### 3.6.2 信息来源优先级(已讨论结论)
- WebSearch 负责:补充通用知识(如课程信息、学习路径共识、考试结构常识)。
- 用户输入负责:表达个人偏好与约束(强度、时段、节奏、目标侧重)。
- 冲突处理:用户偏好优先,通用知识仅作参考与兜底。
#### 3.6.3 字段确认策略(已讨论结论)
- 关键字段:必须用户确认后落库。
- 普通字段:允许静默落库,并在结果摘要中可追溯展示。
#### 3.6.4 成功标准(草案)
- 草案采纳率(用户直接采纳完整草案的比例)。
- 草案修改率(用户修改后采纳的比例)。
- 后续优化收敛效率(基于该草案进入主动优化后的平均有效轮次变化)。
---
## 4. 核心体验原则(已讨论 v1.0
### 4.1 体验总纲(草案)
- 原则 1先看全局再做局部。
- 先识别主要矛盾,再执行局部调整,避免“盲调”。
- 原则 2单轮单主问题域。
- 每轮只聚焦一个主问题域,降低震荡与来回改动。
- 原则 3每轮必须复盘并判定有效性。
- 任何调整都要有“是否变好”的结论,不允许无结论进入下一轮。
- 原则 4达标即收口。
- 达到可接受阈值后立即停止,避免过度优化。
- 原则 5偏好优先、科学兜底。
- 用户偏好是目标方向,科学原则提供安全边界。
- 原则 6硬约束优先于体验优化。
- 先保证不违约束,再追求负载/节奏/切换等体验改进。
### 4.2 单轮优化行为规范(草案)
- 规范 A本轮开始前必须声明“主问题域 + 目标变化”。
- 规范 B单轮仅允许一个主问题域允许附带次问题观察但不展开动作。
- 规范 C同一主问题域若尚未出现有效改进不应频繁切换到其他问题域。
- 规范 D若用户明确指定优化方向优先采用用户方向作为本轮主问题域。
### 4.3 单轮复盘输出规范(草案)
- 每轮都应给出三段式结果:
- 本轮目标:本轮要改善什么。
- 本轮改动:改了哪些关键位置。
- 本轮结果:哪些指标或问题严重度发生了变化。
- 单轮判定结果仅允许两类:
- `有效改进`:至少一个核心问题域严重度下降。
- `无效改进`:无严重度下降,需换策略或收口。
### 4.4 收口与停机原则(已定)
- 正常收口条件:
- 达到可接受方案阈值;
- 或主要问题已降至可接受等级。
- 防循环停机条件:
- 连续多轮无有效改进;
- 或达到轮次上限(当前上限 60
- 强制人工确认规则(已定):
- 只要涉及“移动类改动”,默认都需用户确认后执行。
- 仅当用户显式开启“始终同意”时,允许自动通过确认。
- 即使自动通过,也需在结果中保留可追溯记录。
### 4.5 本章已确定结论
- Q4-1 结论:支持用户强制覆盖单轮主问题域。
- 说明:前端已支持用户自由拖动,该能力与产品原则一致。
- Q4-2 结论:采用“移动必确认,始终同意可自动通过”的统一规则。
- 说明:确认链路以用户控制权优先,兼顾效率模式。
---
## 5. 主动优化产品流程(已讨论 v1.0
### 5.0 模式切换策略(补充,已定)
- 首次主动排课(粗排 + 主动微调)默认启用全流程模式。
- 后续局部调整请求默认启用局部执行模式(优先旧工具链)。
- 仅在以下情况升级为全流程模式:
- 用户明确授权“重新全局优化”;
- 用户诉求明确命中指标域(如切换过多、太满、容错不足等)。
### 5.1 流程总览(已定)
1. 入场判定:确定本次优化模式(默认中位 / 偏好驱动)、目标窗口、可改动范围。
2. 首轮体检:强制先体检,再进入改动(避免盲调)。
3. 迭代优化:按“单轮主问题域”执行改动与复盘。
4. 收口判定:达标即收口;未达标则继续循环。
5. 异常处理:冲突、失败、用户改目标时按规则回退或重开。
6. 结果交付:输出改动摘要、改进证据、剩余风险与下一步建议。
### 5.2 轮次定义(已定)
- “1 轮优化”定义为一次完整闭环:
1. 选定主问题域;
2. 生成本轮改动方案;
3. 通过确认门禁;
4. 执行改动;
5. 复盘并判定有效/无效。
- 说明:
- 仅观察不改动,不计入优化轮。
- “连续无效轮次”仅统计“已执行改动但未出现有效改进”的轮。
### 5.3 详细流程规则(已定)
#### 5.3.1 入场判定
- 输入:用户目标、偏好、限制、当前日程状态。
- 输出:本次优化上下文(模式、范围、约束、初始问题池)。
- 规则:若用户目标不明确,默认按中位最佳实践入场。
- 规则补充:
- 局部执行模式可跳过全流程体检,直接做最小必要校验后执行。
- 全流程模式必须先体检再改动。
#### 5.3.2 首轮体检(强制)
- 必须先完成体检再改动。
- 体检结果至少包含:问题清单、严重度排序、建议主问题域。
- 禁止跳过体检直接执行改动。
#### 5.3.3 单轮优化执行
- 每轮必须先声明:本轮主问题域与目标变化。
- 本轮仅允许一个主问题域,避免并发多目标拉扯。
- 涉及移动类改动:
- 默认需用户确认;
- 用户开启“始终同意”后可自动通过;
- 自动通过仍需可追溯记录。
#### 5.3.4 单轮复盘判定
- 有效改进标准:至少一个核心问题域严重度下降。
- 无效改进标准:执行改动后无严重度下降。
- 无效轮次处置:允许换策略继续,但需计入连续无效轮次计数。
### 5.4 收口规则(已定)
- 正常收口阈值:
- `critical = 0`
- `warning <= 1`
- 防循环强制收口:
- 连续无效轮次 >= 3
- 或达到总轮次上限(当前 60 轮)。
- 收口后必须输出:已解决问题、未解决问题、建议后续动作。
### 5.5 用户中途改目标处理(已定)
- 当用户在优化过程中明确变更目标/偏好时:
- 立即重开“入场判定”;
- 清空当前主问题域上下文;
- 基于新目标重新体检并进入下一轮。
- 目的:避免沿旧目标继续优化导致结果跑偏。
### 5.6 本章已确定结论
- 首轮体检强制执行。
- 可接受阈值采用 `critical=0 且 warning<=1`
- 连续无效 3 轮即强制收口。
- 用户中途改目标时,必须重开入场判定。
- 首次主动排课默认全流程;后续局部调整默认旧工具链。
---
## 6. 科学安排原则(已讨论 v1.0
### 6.1 原则优先级(已定)
按“上位约束可否决下位偏好”的顺序执行:
1. 硬约束合法性(不可冲突、不可越界、不可违规改动)
2. 截止与时间压力(先保证不发生明显延期风险)
3. 用户偏好方向(在上位约束允许范围内优先满足)
4. 负载均衡(避免极端堆积与突增)
5. 认知切换(控制高频切换与过长连续块)
6. 容错能力(可用空窗规模,平衡稳定性与利用率)
### 6.2 冲突裁决规则(已定)
| 冲突场景 | 裁决规则 | 用户可覆盖性 |
|---|---|---|
| 用户偏好 vs 硬约束合法性 | 硬约束优先,拒绝违规方案并给替代建议 | 不可覆盖 |
| 用户偏好 vs 截止/时间压力红线 | 截止压力优先,默认前移高风险任务 | 可显式确认后覆盖部分策略 |
| 用户偏好 vs 下位优化项(负载/切换/容错) | 用户偏好优先,科学原则兜底 | 可覆盖 |
| 无明确用户偏好 | 采用中位最佳实践 | 不适用 |
### 6.3 原则刻度化口径(中位默认 + 双向偏移)
| 原则维度 | 中位默认 | 左偏 | 右偏 |
|---|---|---|---|
| 负载强度 | 平衡推进 | 低强度(更松) | 冲刺强度(更满) |
| 截止推进 | 均衡前移 | 早缓冲(更早完成) | 临近冲刺(更晚推进) |
| 认知切换 | 适度切换 | 低切换(同类聚合) | 高切换(灵活穿插) |
| 容错能力 | 平衡容错 | 高容错(多留大空窗) | 低容错(任务排得更满) |
### 6.4 软硬约束分层(已定)
- 硬约束:
- 合法性约束(冲突、越界、禁止改动范围)
- 截止/时间压力红线
- 软约束:
- 负载均衡
- 认知切换
- 容错能力
- 执行原则:
- 先满足硬约束,再在软约束内做偏好优化。
### 6.5 本章已确定结论
- 科学原则优先级已固定为“硬约束与截止优先,偏好次之,其余体验项随后优化”。
- 冲突裁决已固定为“分层裁决”:不可覆盖项直接否决,可覆盖项通过显式确认处理。
- “容错”作为用户可理解维度,已替代“空窗/缓冲”作为统一外显术语。
---
## 7. 用户需求与偏好模型(已讨论 v1.0
### 7.1 边界定义(已定)
- 本章只定义“偏好消费与确认规则”,不定义“偏好采集机制”。
- 偏好采集由 memory 系统负责:
- 持续采集;
- 去重注入;
- 产品层直接消费。
### 7.2 偏好消费优先级(已定)
1. 用户显式输入(最高优先级)
2. memory 注入偏好(次优先)
3. WebSearch 通用知识(仅补全,不可覆盖用户偏好)
4. 无信息时采用中位默认值
### 7.3 必要点判定与 ask_user 规则(已定)
- 必要点定义:缺失会导致方案不可执行或高风险误判的关键信息。
- 必要点缺失时:必须 ask_user不允许静默推断。
- 当前必要点清单:
- 时间窗(至少明确 endstart 可按策略补齐);
- 强度方向(均匀/冲刺);
- 容错偏好(高容错/平衡/低容错);
- 禁排时段(若用户表达了禁忌但未结构化)。
### 7.4 字段分级(已定)
#### 7.4.1 关键字段(必须确认)
- 时间窗start/end截止时间统一归入 end不单列重复字段
- 强度策略(均匀/冲刺)
- 总预算total_slots
- 容错偏好(高容错/平衡/低容错)
- 禁排时段excluded_slots
- 任务项清单完整性(是否齐全)
- 任务项优先级/依赖关系(如用户提供)
#### 7.4.2 普通字段(可静默落)
- 推荐时段偏好权重(上午/下午/晚间)
- 同类任务聚合偏好(聚合/平衡/穿插)
- 阶段里程碑拆分建议
- 标准化知识标签与学习路径备注(命中统一标准时结构化落地;未命中仅文本备注)
### 7.5 口径修正(已定)
- 不在偏好层管理“单次学习块长度”:
- 该项属于任务类/任务项结构属性,不作为本章普通偏好字段。
- 统一命名“时间窗”:
- “截止时间”视为时间窗 end 的口语表达,不单列独立字段。
### 7.6 本章已确定结论
- 偏好由 memory 采集,产品层只做消费与确认。
- 必要点缺失必须 ask_user避免静默误判。
- 字段分级与统一命名口径已固定,可直接指导后续工具设计与交互文案。
---
## 8. 工具能力产品蓝图(已讨论 v1.0
### 8.1 工具分层(产品视角)
- 事实读取层:告诉 AI“现在是什么”
- 分析体检层:告诉 AI“问题在哪”
- 评估复盘层:告诉 AI“这轮是否变好”
- 执行动作层:让 AI 进行可控调整(以旧工具链为主)
### 8.2 混合工具策略(新增)
- 策略 1旧工具保留为主执行层不做全线替换。
- 策略 2新分析工具作为导航层主要用于首次主动排课与指标域重优化。
- 策略 3局部请求默认旧工具直达执行避免过度主动出击。
- 策略 4仅在用户授权或命中指标域诉求时升级为分析链路。
### 8.3 对话内能力(草案)
| 能力 | 适用模式 | 用户价值 | AI 产出 | 风险控制 |
|---|---|---|---|---|
| analyze_health总览体检 | 首次编排/明确触发全流程时默认首入口(可跳过) | 快速定位主要问题 | metrics/issues/next_actions | 防盲钻、防误判 |
| analyze_load | 全流程模式/指标域触发 | 识别过载与波动 | 负载证据 + 动作建议 | 防局部最优 |
| analyze_subjects | 全流程模式/指标域触发 | 识别科目节奏与预算压力 | 分布证据 + 动作建议 | 防断档 |
| analyze_context | 全流程模式/指标域触发 | 识别切换过高与碎片化 | 切换证据 + 动作建议 | 防认知疲劳 |
| analyze_tolerance | 全流程模式/指标域触发 | 识别容错不足风险 | 容错证据 + 动作建议 | 防计划脆弱 |
| build_task_class_draftWebSearch增强 | 共创模式 | 从 0 到 1 生成可用任务类草案 | 完整任务类草案 + 关键字段确认请求 | 防知识幻觉、防越权落库 |
### 8.4 分析工具输出结构规范(草案)
- 分析工具统一返回三段:
- `metrics`:测量值;
- `issues`问题及严重度critical/warning/info
- `next_actions`:下一步建议(只建议,不自动执行)。
- 细节级别:
- 默认 `summary`
- 用户追问或需要取证时使用 `full`
### 8.5 WebSearch 共创能力边界(新增)
- 本能力定位:对话内共创,不替代主动优化主线。
- 输出形态:完整任务类草案,不是单字段建议。
- 决策边界:用户偏好优先于通用知识。
- 安全边界:关键字段需确认,普通字段可静默落并可追溯。
### 8.6 本章已确定结论
- `analyze_health` 仅在“首次编排”或“用户明确触发全流程”时作为默认首入口(可跳过)。
- 分析工具默认明细级别统一为 `summary`,用户追问或需取证时切换 `full`
---
## 9. 关键体验与交互要求(已讨论 v1.0
### 9.1 本章定位(已对齐)
- 本章只定义“用户看到什么、怎么被解释、何时需要确认”。
- 不定义算法细节、不定义工具内部实现。
- 目标是让主动优化“有方向、可理解、不过度”。
### 9.2 双模式对话体验(已对齐)
- 首次编排/明确触发全流程时:进入“体检 + 迭代优化”模式,先给全局判断,再给单轮改进。
- 后续局部请求时:默认走旧工具的局部执行链,不擅自升级为全流程。
- 仅在两类条件下可升级全流程:用户明确授权;用户诉求明确命中指标域(如“切换太多”“太满了”)。
### 9.3 单轮解释三段式(已定)
- 观察段:本轮先说“我看到了什么问题”,并给最小证据(指标或现象)。
- 动作段:再说“我准备怎么改、为什么这么改”,同时点明遵循了哪条科学原则与用户偏好。
- 结果段:最后说“改完发生了什么变化”,并给下一步建议(继续微调或收口)。
- 三段式的意义:让用户始终知道“问题-动作-结果”的闭环,避免 AI 黑箱式挪动。
### 9.4 解释字段最小集合(已定)
- 字段1必显本轮主问题域负载/切换/截止/容错/科目分布等)。
- 字段2必显本轮改动摘要改了哪些任务、从哪到哪、影响了哪几天
- 字段3必显改动理由科学原则 + 用户偏好 + 冲突裁决依据)。
- 字段4建议显前后对比至少 1 个核心指标变化)。
- 字段5建议显副作用提示例如“容错下降”“切换略增”
- 字段6建议显下一步建议继续某方向微调或建议收口
- 默认规则:最少展示前 3 字段;全流程场景建议展示 1-6 字段。
### 9.5 用户控制与确认边界(已对齐)
- 涉及“移动类改动”默认都要确认;若用户已开启“始终同意”,可自动通过但需可追溯。
- 用户可自由手动拖动,系统应尊重手动结果,不反向强改。
- 用户可随时改目标;改目标后按既定规则重开入场判定。
- AI 可主动给建议,但不能越权执行超出用户授权范围的改动。
### 9.6 对话内任务类共创体验(已对齐)
- 仅聊天触发,不做聊天外按钮触发。
- 输出形态为“完整任务类草案”,而非零散参数建议。
- 关键字段必须确认;普通字段可静默落并保留可追溯记录。
- 用户偏好与 Web 通用知识冲突时,用户偏好优先。
### 9.7 本章已确定结论
- 默认解释风格采用“专业结论 + 通俗补充”双层表达。
- 最小必显字段固定为 3 项:主问题域、改动摘要、改动理由。
- 局部模式下不强制固定边界提示,是否提示由上下文按需决定。
---
## 10. 风险、边界与治理(已讨论 v1.0
### 10.1 风险分层(产品视角)
- R1 收敛风险LLM 长时间小步试探但无实质改进,造成轮次浪费。
- R2 体验风险:指标看起来改善,但用户主观体感变差(例如更累、更碎)。
- R3 越权风险AI 在未充分授权下做了超出预期范围的改动。
- R4 可信风险:解释与真实改动不一致,导致用户不信任系统。
- R5 数据风险:关键信息缺失/冲突,导致判断前提不成立却仍继续优化。
### 10.2 产品边界(已对齐)
- 边界1全流程优化默认仅用于首次编排或用户明确触发后续局部请求默认局部执行。
- 边界2涉及移动类改动默认确认用户开启“始终同意”后可自动通过但需保留追溯。
- 边界3用户手动拖动结果优先AI 不得反向强改。
- 边界4用户可随时改目标改目标后立即重开入场判定。
- 边界5用户偏好与通用知识冲突时用户偏好优先。
### 10.3 治理机制(过程治理)
- 入场治理:先判定是“全流程模式”还是“局部模式”;必要信息缺失必须 ask_user不允许静默猜测。
- 轮中治理:坚持单轮单主问题域;每轮都输出“观察-动作-结果”,并判断是否有效改进。
- 收口治理:命中 `critical=0 且 warning<=1` 立即收口;连续无效 3 轮或达到轮次上限强制收口。
- 出口治理:收口时必须显式说明“当前残留问题 + 可选后续动作”,避免用户误以为已全局最优。
### 10.4 强制确认清单(已定)
- A类必须确认任何会导致任务/课程位置变化的移动类改动(已拍板规则)。
- B类必须确认会改变用户明确声明偏好的改动如偏好时段、偏好节奏
- C类必须确认一次影响多个日期的大范围联动调整避免“无感大改”
- 说明A/B/C 三类均为硬规则;若用户开启“始终同意”,可自动通过但须完整追溯。
### 10.5 “禁止 AI 改动清单”能力(已定)
- 能力定义:用户可声明一组“不可被 AI 主动改动”的对象或范围(例如某类固定课程/某些日期)。
- 产品意义:降低越权风险,提升高控制型用户的信任感。
- 首发口径:支持“对话内声明即生效”的轻量禁改语义;通过现有上下文注入链路生效,本期不新增 agent 侧治理改动。
- 后续演进:配置化、持久化禁改清单能力纳入后续阶段评估。
### 10.6 可追溯与回退要求(已定)
- 每轮必须可追溯:至少记录主问题域、改动摘要、改动理由、影响范围、确认来源。
- 对“已执行改动”应支持最小粒度回退能力,避免用户对试错型优化产生风险焦虑。
- 回退后应触发一次简版复盘,避免回退导致隐性冲突未被感知。
- 首发最低要求:至少支持“回退最近一轮已执行改动”;多版本日程管理(多轮历史回退)纳入 P2。
### 10.7 本章已确定结论
- 强制确认范围升级为 A/B/C 三类全部硬规则。
- 首发纳入“禁止 AI 改动清单(对话内轻量版)”。
- 回退能力首发最低要求为“回退最近一轮”,多版本管理纳入 P2。
---
## 11. 目标指标与验收标准(已讨论 v1.0
### 11.1 指标设计原则(已对齐)
- 原则1指标必须服务于“首次编排全流程”主场景不用局部请求噪声稀释判断。
- 原则2指标必须同时覆盖“结果好不好、过程稳不稳、体验可不可信”三层。
- 原则3指标必须可落地采集避免依赖大量主观人工打分。
### 11.2 首发核心指标(已定)
| 指标层级 | 指标名 | 指标定义(产品口径) | 首发目标 |
|---|---|---|---|
| 结果指标 | 首次编排可接受收口率 | 首次编排全流程中,满足 `critical=0 且 warning<=1` 并进入收口的会话占比 | >= 70% |
| 过程指标 | 有效优化轮次占比 | 全流程会话内,“有效轮次”占总轮次比例 | >= 50% |
| 质量指标 | 无效回摆率 | 近两轮内被反向撤回的改动占全部改动比例(衡量“折返跑”) | <= 15% |
### 11.3 关键口径定义(已定)
- 有效优化轮次:至少满足“一个核心问题域严重度下降”,且不引入新的 `critical` 问题。
- 可接受收口:达到既定收口阈值(`critical=0 且 warning<=1`)并完成收口说明。
- 无效回摆:同一任务/课程在短窗口内出现“改过去又改回来”的反向变更。
### 11.4 辅助观测指标(不作为首发硬门槛)
- 平均收口轮次:成功收口会话平均用了多少轮(用于评估效率,不单独卡上线)。
- 强制确认后撤销率:已确认改动后被用户撤销的比例(用于识别解释质量问题)。
- 对话内追问率:用户对“为什么这么改”继续追问的比例(用于评估解释清晰度)。
### 11.5 验收规则(已定)
- 验收窗口:按自然周滚动观测,至少连续 2 个观察窗口达标再判定“阶段通过”。
- 达标判定:第 11.2 的 3 个核心指标同时达标。
- 未达标处理:按指标归因回到对应章节优化(流程、工具、解释、确认边界),不允许只调阈值“做数字”。
### 11.6 本章已确定结论
- 首发核心指标冻结为:可接受收口率 + 有效优化轮次占比 + 无效回摆率。
- “有效优化轮次”口径冻结为:至少一个问题域下降,且不新增 `critical`
- 首发目标值冻结为:`>=70% / >=50% / <=15%`
---
## 12. 分期路线图(已讨论 v1.0
### 12.1 分期原则(执行导向)
- 原则1先闭环再扩面。先把“首次编排可收敛”做扎实再扩展高级能力。
- 原则2每期都有“明确不做”避免执行期目标漂移。
- 原则3每期必须有可量化出场标准未达标不进入下一期主目标。
### 12.2 分期总览(已定)
| 阶段 | 核心目标 | 必做交付范围(产品) | 明确不做(冻结范围) | 出场标准(产品) |
|---|---|---|---|---|
| Phase 1 | 建立首次编排的主动优化闭环 | 首次编排默认全流程后续局部默认旧工具6个分析工具口径落地A/B/C三类确认规则最近一轮回退第11章三核心指标可观测 | 不做多版本日程管理;不做配置化禁改清单;不扩展到聊天外触发 | 连续2个观察窗口达到第11章目标值70%/50%/15% |
| Phase 1.5 | 建立对话内任务类共创可用版 | 聊天触发的完整任务类草案;关键字段确认+普通字段静默落用户偏好优先于Web通识 | 不做按钮触发;不做全自动无确认落库;不做课程库平台化治理 | 任务类草案一次可用率达到预设阈值(阈值在阶段启动前冻结) |
| Phase 2 | 强化个性化和治理能力 | 配置化禁改清单;多版本日程管理(含多轮回退);解释与确认策略按用户类型分层 | 不做跨终端复杂编排协同;不做完全自治无人值守优化 | 在保持Phase 1核心指标不退化前提下撤销率与追问率下降 |
| Phase 3 | 平台化与长期稳定性 | 能力模块化复用;跨场景复用统一口径;长期策略调优与治理看板 | 不新增未经验证的大跨度能力域 | 核心指标长期稳定且新增能力不破坏既有闭环 |
### 12.3 Phase 1 最小可用闭环MVP定义已定
- 入口:仅“首次编排”自动进入全流程,或用户明确触发全流程。
- 执行:按既定单轮机制运行(观察-动作-结果并遵守A/B/C确认规则。
- 收口:按既定阈值收口(`critical=0 且 warning<=1`;或触发强制收口)。
- 保障:支持最近一轮回退、保留可追溯记录、支持对话内轻量禁改。
- 验收以第11章三核心指标作为唯一阶段通过标准。
### 12.4 跨期依赖关系(已定)
- Phase 1 是所有后续阶段前置,未通过则不进入 Phase 2 的主交付。
- Phase 1.5 可与 Phase 1 后段并行推进,但不得影响 Phase 1 指标达标。
- Phase 2 的多版本管理与配置化禁改,依赖 Phase 1 的追溯数据结构稳定。
### 12.5 本章已确定结论
- Phase 1 出场标准固定为第11章三核心指标连续 2 个窗口达标。
- Phase 1.5 与 Phase 1 时序固定为:允许后半程并行推进,前提是不影响 Phase 1 指标达标。
- Phase 2 主目标冻结为:配置化禁改清单 + 多版本日程管理。
### 12.6 当前执行优先级(新增)
- 当前版本优先目标为“先跑通 Phase 1 ~ Phase 1.5”。
- Phase 2 / Phase 3 暂缓,待前两阶段稳定后再回到路线图继续推进。
---
## 13. 待决策清单(滚动更新)
| 编号 | 议题 | 决策选项 | 当前状态 | 负责人 |
|---|---|---|---|---|
| D-001 | 对话内主动优化目标优先级 | A>B>C / A=C>B / C>A>B | 已确定A>B>C | 产品 |
| D-002 | WebSearch 任务类设计触发形态 | 聊天触发 / 聊天外按钮触发 | 已确定(聊天触发) | 产品 |
| D-003 | WebSearch 与用户偏好冲突策略 | 通用知识优先 / 用户偏好优先 | 已确定(用户偏好优先) | 产品 |
| D-004 | 任务类草案落库确认策略 | 全字段确认 / 关键字段确认+普通字段静默落 | 已确定(后者) | 产品 |
| D-005 | 任务类草案“小幅修改”阈值 | 20% / 30% / 40% | 已确定30% | 产品 |
| D-006 | 主动优化“有效改进”最小标准 | 严重度下降 / 分数提升 / 二者同时满足 | 已确定(至少一个问题域严重度下降) | 产品 |
| D-007 | 用户是否可强制覆盖单轮主问题域 | 支持 / 不支持 / 有条件支持 | 已确定(支持) | 产品 |
| D-008 | 强制人工确认触发条件 | 精简2类 / 标准3类 / 扩展4类+ | 已确定(涉及移动默认确认;始终同意可自动通过) | 产品 |
| D-009 | 连续无效轮次强制收口阈值 | 2 / 3 / 4 | 已确定3 | 产品 |
| D-010 | 可接受方案阈值 | critical=0且warning<=0/1/2 | 已确定critical=0 且 warning<=1 | 产品 |
| D-011 | 用户中途改目标处理策略 | 延续当前轮 / 下轮生效 / 立即重开入场判定 | 已确定(立即重开入场判定) | 产品 |
| D-012 | 科学原则优先级 | 多种排序方案 | 已确定(硬约束 > 截止压力 > 用户偏好 > 负载 > 切换 > 容错) | 产品 |
| D-013 | 原则冲突裁决口径 | 用户优先 / 科学优先 / 分层裁决 | 已确定(分层裁决) | 产品 |
| D-014 | 偏好模型边界 | 产品层负责采集+消费 / 仅消费不采集 | 已确定(仅消费不采集) | 产品 |
| D-015 | 必要点缺失处理 | 静默推断 / ask_user / 混合策略 | 已确定(必要点缺失必须 ask_user | 产品 |
| D-016 | 后续局部请求默认模式 | 全流程优先 / 局部执行优先 | 已确定(局部执行优先) | 产品 |
| D-017 | 旧工具与新工具关系 | 全替换 / 并行混合 | 已确定(并行混合,旧工具主执行) | 产品 |
| D-018 | `analyze_health` 默认入口触发条件 | 全程默认 / 首次与明确触发默认 | 已确定(首次与明确触发默认) | 产品 |
| D-019 | 分析工具默认明细级别 | summary / full | 已确定summary | 产品 |
| D-020 | 第九章默认解释风格 | 纯专业 / 纯通俗 / 专业结论+通俗补充 | 已确定(专业结论+通俗补充) | 产品 |
| D-021 | 第九章最小必显字段 | 2项 / 3项 / 4项+ | 已确定3项 | 产品 |
| D-022 | 局部模式是否固定边界提示 | 固定提示 / 按需提示 | 已确定(按需提示) | 产品 |
| D-023 | 第十章强制确认范围 | 仅A类移动类硬规则 / A+B类硬规则 / A+B+C类硬规则 | 已确定A+B+C类硬规则 | 产品 |
| D-024 | 首发是否支持禁改清单 | 不支持 / 支持对话内轻量版 / 直接支持配置化 | 已确定(支持对话内轻量版) | 产品 |
| D-025 | 回退能力最低要求 | 不要求 / 回退最近一轮 / 多轮可选回退 | 已确定回退最近一轮多版本管理纳入P2 | 产品 |
| D-026 | 第十一章首发核心指标组合 | 多种组合方案 | 已确定(收口率+有效轮次占比+无效回摆率) | 产品 |
| D-027 | “有效优化轮次”口径 | 仅严重度下降 / 严重度下降且不新增critical / 复合打分 | 已确定严重度下降且不新增critical | 产品 |
| D-028 | 第十一章首发目标值 | 激进/中性/保守三档 | 已确定70% / 50% / 15% | 产品 |
| D-029 | Phase 1 出场标准 | 三核心指标连续1/2/3窗口达标 | 已确定连续2窗口 | 产品 |
| D-030 | Phase 1.5 与 Phase 1 时序 | 串行 / 后半程并行 / 完全并行 | 已确定(后半程并行) | 产品 |
| D-031 | Phase 2 主目标冻结范围 | 多方案 | 已确定(配置化禁改+多版本管理) | 产品 |
| D-032 | 当前版本执行优先级 | 全路线并推 / 先P1~P1.5后续暂缓 | 已确定先P1~P1.5后续暂缓) | 产品 |
---
## 14. 章节讨论记录(按“讨论一章、定一章”推进)
### 记录模板
- 讨论章节:
- 结论:
- 未决问题:
- 下一步动作:
- 更新时间:
### 已讨论记录
- 讨论章节:第 1 章 业务背景与问题定义
- 结论:采用“双模式策略”(默认中位最佳实践 + 偏好优先偏移);读工具按“广覆盖+区间指标”设计;自动优化轮次上限暂定 60。
- 未决问题:时长目标与是否默认开启深度思考的策略未冻结。
- 下一步动作:进入第 2 章,冻结“满意方案”与目标优先级定义。
- 更新时间2026-04-24
- 讨论章节:第 2 章 产品目标与非目标
- 结论:目标优先级确定为 A自主迭代收敛> B可解释与改进证据> C对话内任务类共创草案首发先保 A+B 闭环C 走可用版。
- 未决问题C 可用版的覆盖范围与补全字段边界待在第 8 章细化。
- 下一步动作:进入第 3 章,明确首发用户分层与高频场景清单。
- 更新时间2026-04-24
- 讨论章节:第 3 章补充议题 WebSearch 任务类共创
- 结论:定位为“对话内触发、产出完整任务类草案”的增强能力;知识来源为 WebSearch 通用信息 + 用户偏好,冲突时用户优先;字段按关键/普通分级确认。
- 未决问题:关键字段名单与普通字段名单待在后续章节细化。
- 下一步动作:在第 8 章与第 12 章细化能力边界与分期。
- 更新时间2026-04-24
- 讨论章节:第 3 章阈值口径补充S1/S2
- 结论S1 采用“关键字段修改率<=30%”作为小幅修改阈值S2 采用“至少一个核心问题域严重度下降”作为有效改进最小标准。
- 未决问题:关键字段清单与核心问题域枚举待后续章节细化。
- 下一步动作:推进第 4 章核心体验原则,固化“单轮单问题域 + 复盘判定”。
- 更新时间2026-04-24
- 讨论章节:第 3 章 用户与场景v1.0
- 结论用户分层、首发场景、场景优先级、暂不支持边界、S1/S2/S3 判定口径均已形成可冻结版本。
- 未决问题:无(本章内容进入后续引用阶段)。
- 下一步动作:推进第 4 章,明确“单轮策略、复盘规范、停机确认”的执行口径。
- 更新时间2026-04-24
- 讨论章节:第 4 章 核心体验原则v0.1 草案)
- 结论:已形成“总纲-单轮规范-复盘规范-停机原则”的完整草案结构。
- 未决问题D-007用户强制覆盖策略与 D-008强制确认触发条件待拍板。
- 下一步动作:根据 D-007/D-008 决策冻结第 4 章。
- 更新时间2026-04-24
- 讨论章节:第 4 章 核心体验原则v1.0
- 结论:支持用户强制覆盖单轮主问题域;涉及移动类改动默认确认,用户开启“始终同意”后可自动通过并保留追溯记录。
- 未决问题:无(本章已冻结)。
- 下一步动作:进入第 5 章,细化主动优化流程与收口判定口径。
- 更新时间2026-04-24
- 讨论章节:第 5 章 主动优化产品流程v1.0
- 结论明确了“轮次定义、首轮强制体检、单轮执行闭环、连续无效3轮收口、critical=0且warning<=1收口、用户改目标即重开入场判定”。
- 未决问题:无(本章已冻结)。
- 下一步动作:进入第 6 章,细化科学安排原则与冲突优先级口径。
- 更新时间2026-04-24
- 讨论章节:第 6 章 科学安排原则v1.0
- 结论:优先级确定为“硬约束 > 截止压力 > 用户偏好 > 负载 > 切换 > 容错”;冲突裁决采用分层规则;“容错”作为统一用户解释术语。
- 未决问题:无(本章已冻结)。
- 下一步动作:进入第 7 章,细化偏好模型与关键字段清单。
- 更新时间2026-04-24
- 讨论章节:第 7 章 用户需求与偏好模型v1.0
- 结论:偏好采集由 memory 负责,产品层仅消费;必要点缺失必须 ask_user关键/普通字段分级与“时间窗”统一口径已确定。
- 未决问题:无(本章已冻结)。
- 下一步动作:进入第 8 章,细化工具能力蓝图与工具边界。
- 更新时间2026-04-24
- 讨论章节:第 8 章补充议题(首次全流程 vs 后续局部执行)
- 结论:首次主动排课默认全流程;后续局部请求默认旧工具链;仅在授权或命中指标域诉求时升级分析链路。
- 未决问题:`analyze_health` 是否固定为默认首入口(可跳过)仍待拍板。
- 下一步动作:继续冻结第 8 章细项后推进第 9 章。
- 更新时间2026-04-24
- 讨论章节:第 8 章 工具能力产品蓝图v1.0
- 结论:`analyze_health` 仅在首次编排或明确触发全流程时默认首入口;分析工具默认 `summary`,按需切换 `full`
- 未决问题:无(本章已冻结)。
- 下一步动作:进入第 9 章,细化对话内体验文案与解释字段规范。
- 更新时间2026-04-24
- 讨论章节:第 9 章 关键体验与交互要求v0.1 草案)
- 结论:已形成“双模式体验 + 单轮三段式解释 + 最小解释字段 + 用户控制边界 + 共创体验”的完整草案。
- 未决问题D-020默认解释风格、D-021最小必显字段数量、D-022局部模式固定边界提示待拍板。
- 下一步动作:完成 D-020~D-022 拍板后冻结第 9 章,进入第 10 章风险与治理。
- 更新时间2026-04-24
- 讨论章节:第 9 章 关键体验与交互要求v1.0
- 结论:解释风格定为“专业结论+通俗补充”;最小必显字段固定 3 项;局部模式边界提示改为按需提示;第 9 章冻结。
- 未决问题:无(本章已冻结)。
- 下一步动作:进入第 10 章,讨论风险、边界与治理策略。
- 更新时间2026-04-24
- 讨论章节:第 10 章 风险、边界与治理v0.1 草案)
- 结论:已形成“风险分层 + 过程治理 + 强制确认分级 + 禁改清单 + 回退追溯”的完整草案结构。
- 未决问题D-023强制确认范围、D-024禁改清单首发形态、D-025回退能力最低要求待拍板。
- 下一步动作:完成 D-023~D-025 拍板后冻结第 10 章,进入第 11 章指标与验收。
- 更新时间2026-04-24
- 讨论章节:第 10 章 风险、边界与治理v1.0
- 结论:强制确认范围定为 A/B/C 全硬规则;首发支持对话内轻量禁改清单;回退最低要求定为“最近一轮”,多版本管理纳入 P2第 10 章冻结。
- 未决问题:无(本章已冻结)。
- 下一步动作:进入第 11 章,讨论目标指标与验收标准。
- 更新时间2026-04-24
- 讨论章节:第 11 章 目标指标与验收标准v0.1 草案)
- 结论:已形成“首发三核心指标 + 关键口径定义 + 验收窗口规则”的完整草案结构。
- 未决问题D-026核心指标组合、D-027有效轮次口径、D-028首发目标值待拍板。
- 下一步动作:完成 D-026~D-028 拍板后冻结第 11 章,进入第 12 章分期路线图。
- 更新时间2026-04-24
- 讨论章节:第 11 章 目标指标与验收标准v1.0
- 结论:首发核心指标冻结为“收口率+有效轮次占比+无效回摆率”有效轮次口径冻结为“问题域下降且不新增critical”目标值冻结为“70% / 50% / 15%”;第 11 章冻结。
- 未决问题:无(本章已冻结)。
- 下一步动作:进入第 12 章,讨论分期路线图与每期冻结范围。
- 更新时间2026-04-24
- 讨论章节:第 12 章 分期路线图v0.1 草案)
- 结论:已形成“分期总览 + 每期明确不做 + 出场标准 + 跨期依赖”的执行导向草案。
- 未决问题D-029Phase 1出场标准窗口数、D-030Phase 1.5与Phase 1时序、D-031Phase 2主目标冻结范围待拍板。
- 下一步动作:完成 D-029~D-031 拍板后冻结第 12 章。
- 更新时间2026-04-24
- 讨论章节:第 12 章 分期路线图v1.0
- 结论Phase 1 出场标准定为连续2窗口达标Phase 1.5 采用后半程并行Phase 2 主目标冻结为“配置化禁改+多版本管理”;当前执行优先级定为先跑通 P1~P1.5、后续阶段暂缓;第 12 章冻结。
- 未决问题:无(本章已冻结)。
- 下一步动作:进入收尾阶段,统一检查决策表与章节状态一致性。
- 更新时间2026-04-24
---
## 15. 术语表(持续补充)
| 术语 | 业务定义 |
|---|---|
| 主动优化 | AI 连续观测-调整-复盘-收口的优化过程 |
| 收口 | 达到阈值后停止迭代并输出最终方案 |
| 主问题域 | 单轮优化聚焦的首要问题类型 |

View File

@@ -63,6 +63,7 @@ func RegisterRouters(handlers *api.ApiHandlers, cache *dao.CacheDAO, userRepo *d
{
courseGroup.Use(middleware.JWTTokenAuth(cache), middleware.RateLimitMiddleware(limiter, 20, 1))
courseGroup.POST("/validate", handlers.CourseHandler.CheckUserCourse)
courseGroup.POST("/parse-image", handlers.CourseHandler.ParseCourseTableImage)
courseGroup.POST("/import", middleware.IdempotencyMiddleware(cache), handlers.CourseHandler.AddUserCourses)
}
taskClassGroup := apiGroup.Group("/task-class")

View File

@@ -6,6 +6,7 @@ import (
"github.com/LoveLosita/smartflow/backend/conv"
"github.com/LoveLosita/smartflow/backend/dao"
infrallm "github.com/LoveLosita/smartflow/backend/infra/llm"
"github.com/LoveLosita/smartflow/backend/model"
"github.com/LoveLosita/smartflow/backend/respond"
)
@@ -14,13 +15,25 @@ type CourseService struct {
// 伸出手:准备接住 DAO
courseDAO *dao.CourseDAO
scheduleDAO *dao.ScheduleDAO
courseImageResponsesClient *infrallm.ArkResponsesClient
courseImageConfig CourseImageParseConfig
courseImageModel string
}
// NewCourseService 创建 CourseService 实例
func NewCourseService(courseDAO *dao.CourseDAO, scheduleDAO *dao.ScheduleDAO) *CourseService {
func NewCourseService(
courseDAO *dao.CourseDAO,
scheduleDAO *dao.ScheduleDAO,
courseImageResponsesClient *infrallm.ArkResponsesClient,
courseImageConfig CourseImageParseConfig,
courseImageModel string,
) *CourseService {
return &CourseService{
courseDAO: courseDAO,
scheduleDAO: scheduleDAO,
courseImageResponsesClient: courseImageResponsesClient,
courseImageConfig: courseImageConfig,
courseImageModel: strings.TrimSpace(courseImageModel),
}
}

View File

@@ -0,0 +1,295 @@
package service
import (
"errors"
"fmt"
"net/http"
"strings"
"github.com/LoveLosita/smartflow/backend/model"
)
const (
defaultCourseImageMaxBytes = 5 * 1024 * 1024
defaultCourseImageMaxTokens = 16384
maxCourseImageDraftRows = 256
courseImageParseTemperature = 0.1
)
var (
ErrCourseImageParserUnavailable = errors.New("course image parser is not configured")
ErrCourseImageTooLarge = errors.New("course image is too large")
ErrCourseImageUnsupportedMIME = errors.New("course image mime type is not supported")
ErrCourseImageEmpty = errors.New("course image is empty")
)
type CourseImageParseConfig struct {
MaxImageBytes int64
MaxTokens int
}
func NewCourseImageParseConfig(maxImageBytes int64, maxTokens int) CourseImageParseConfig {
if maxImageBytes <= 0 {
maxImageBytes = defaultCourseImageMaxBytes
}
if maxTokens <= 0 {
maxTokens = defaultCourseImageMaxTokens
}
return CourseImageParseConfig{
MaxImageBytes: maxImageBytes,
MaxTokens: maxTokens,
}
}
func normalizeCourseImageParseRequest(req model.CourseImageParseRequest, cfg CourseImageParseConfig) (*model.CourseImageParseRequest, error) {
req.Filename = strings.TrimSpace(req.Filename)
req.MIMEType = strings.TrimSpace(strings.ToLower(req.MIMEType))
if len(req.ImageBytes) == 0 {
return nil, ErrCourseImageEmpty
}
if int64(len(req.ImageBytes)) > cfg.MaxImageBytes {
return nil, ErrCourseImageTooLarge
}
detected := strings.ToLower(strings.TrimSpace(http.DetectContentType(req.ImageBytes)))
if req.MIMEType == "" || req.MIMEType == "application/octet-stream" {
req.MIMEType = detected
}
if !isSupportedCourseImageMIME(req.MIMEType) {
if isSupportedCourseImageMIME(detected) {
req.MIMEType = detected
} else {
return nil, ErrCourseImageUnsupportedMIME
}
}
if req.Filename == "" {
req.Filename = "course-table"
}
return &req, nil
}
func isSupportedCourseImageMIME(mimeType string) bool {
switch strings.TrimSpace(strings.ToLower(mimeType)) {
case "image/jpeg", "image/png", "image/webp":
return true
default:
return false
}
}
func normalizeCourseImageParseResponse(resp *model.CourseImageParseResponse) (*model.CourseImageParseResponse, error) {
if resp == nil {
return nil, errors.New("course image parse response is nil")
}
resp.DraftStatus = model.CourseImageParseDraftStatus(strings.ToLower(strings.TrimSpace(string(resp.DraftStatus))))
resp.Message = strings.TrimSpace(resp.Message)
resp.Warnings = normalizeWarningList(resp.Warnings)
resp.Rows = normalizeCourseImageParseRows(resp.Rows, &resp.Warnings)
switch resp.DraftStatus {
case model.CourseImageParseDraftStatusSuccess:
if len(resp.Rows) == 0 {
return nil, errors.New("course image parse response has no rows in success status")
}
for idx := range resp.Rows {
if err := validateCourseImageParseRow(&resp.Rows[idx], true); err != nil {
return nil, fmt.Errorf("course image parse success row %d invalid: %w", idx+1, err)
}
}
case model.CourseImageParseDraftStatusPartial:
if len(resp.Rows) == 0 {
return nil, errors.New("course image parse response has no rows in partial status")
}
for idx := range resp.Rows {
if err := validateCourseImageParseRow(&resp.Rows[idx], false); err != nil {
return nil, fmt.Errorf("course image parse partial row %d invalid: %w", idx+1, err)
}
}
case model.CourseImageParseDraftStatusReject:
resp.Rows = make([]model.CourseImageParseRow, 0)
default:
return nil, fmt.Errorf("unsupported draft_status: %s", resp.DraftStatus)
}
if resp.Message == "" {
resp.Message = defaultCourseImageParseMessage(resp.DraftStatus, len(resp.Rows))
}
return resp, nil
}
func normalizeCourseImageParseRows(rows []model.CourseImageParseRow, warnings *[]string) []model.CourseImageParseRow {
if len(rows) == 0 {
return make([]model.CourseImageParseRow, 0)
}
if len(rows) > maxCourseImageDraftRows {
rows = rows[:maxCourseImageDraftRows]
appendUniqueWarning(warnings, "识别结果行数超过上限,后端已截断为 256 行,请重点核对。")
}
normalized := make([]model.CourseImageParseRow, 0, len(rows))
for idx := range rows {
row := rows[idx]
row.RowID = strings.TrimSpace(row.RowID)
if row.RowID == "" {
row.RowID = fmt.Sprintf("row_%03d", idx+1)
}
row.CourseName = strings.TrimSpace(row.CourseName)
row.Location = strings.TrimSpace(row.Location)
row.WeekType = normalizeCourseImageWeekType(row.WeekType)
row.RawText = strings.TrimSpace(row.RawText)
row.RowWarnings = normalizeWarningList(row.RowWarnings)
normalizeOptionalPositiveInt(&row.StartWeek)
normalizeOptionalPositiveInt(&row.EndWeek)
normalizeOptionalPositiveInt(&row.DayOfWeek)
normalizeOptionalPositiveInt(&row.StartSection)
normalizeOptionalPositiveInt(&row.EndSection)
if row.Confidence < 0 {
row.Confidence = 0
}
if row.Confidence > 1 {
row.Confidence = 1
}
if row.CourseName == "" &&
row.StartWeek == nil &&
row.EndWeek == nil &&
row.DayOfWeek == nil &&
row.StartSection == nil &&
row.EndSection == nil &&
row.RawText == "" {
appendUniqueWarning(warnings, fmt.Sprintf("存在空白草稿行,后端已自动忽略:%s", row.RowID))
continue
}
normalized = append(normalized, row)
}
return normalized
}
func validateCourseImageParseRow(row *model.CourseImageParseRow, strict bool) error {
if row == nil {
return errors.New("row is nil")
}
if strict && row.CourseName == "" {
return errors.New("course_name is empty")
}
if strict && row.WeekType == "" {
return errors.New("week_type is empty")
}
if row.WeekType != "" && row.WeekType != "all" && row.WeekType != "odd" && row.WeekType != "even" {
return fmt.Errorf("week_type is invalid: %s", row.WeekType)
}
if err := validateOptionalCourseIntPair(row.StartWeek, row.EndWeek, 1, 24, "week", strict); err != nil {
return err
}
if err := validateOptionalCourseIntPair(row.StartSection, row.EndSection, 1, 12, "section", strict); err != nil {
return err
}
if strict && row.DayOfWeek == nil {
return errors.New("day_of_week is empty")
}
if row.DayOfWeek != nil && (*row.DayOfWeek < 1 || *row.DayOfWeek > 7) {
return fmt.Errorf("day_of_week out of range: %d", *row.DayOfWeek)
}
return nil
}
func validateOptionalCourseIntPair(start *int, end *int, min int, max int, field string, strict bool) error {
if strict {
if start == nil || end == nil {
return fmt.Errorf("%s range is incomplete", field)
}
}
if start == nil && end == nil {
return nil
}
if start == nil || end == nil {
return fmt.Errorf("%s range is incomplete", field)
}
if *start < min || *start > max {
return fmt.Errorf("%s start out of range: %d", field, *start)
}
if *end < min || *end > max {
return fmt.Errorf("%s end out of range: %d", field, *end)
}
if *start > *end {
return fmt.Errorf("%s start is greater than end: %d > %d", field, *start, *end)
}
return nil
}
func normalizeOptionalPositiveInt(target **int) {
if target == nil || *target == nil {
return
}
if **target <= 0 {
*target = nil
}
}
func normalizeCourseImageWeekType(raw string) string {
normalized := strings.ToLower(strings.TrimSpace(raw))
switch normalized {
case "", "unknown", "null":
return ""
case "all", "every", "weekly", "each week", "每周", "全周", "全部":
return "all"
case "odd", "single", "单", "单周":
return "odd"
case "even", "double", "双", "双周":
return "even"
default:
return normalized
}
}
func normalizeWarningList(items []string) []string {
if len(items) == 0 {
return make([]string, 0)
}
seen := make(map[string]struct{}, len(items))
result := make([]string, 0, len(items))
for _, item := range items {
trimmed := strings.TrimSpace(item)
if trimmed == "" {
continue
}
if _, ok := seen[trimmed]; ok {
continue
}
seen[trimmed] = struct{}{}
result = append(result, trimmed)
}
return result
}
func appendUniqueWarning(target *[]string, warningText string) {
if target == nil {
return
}
trimmed := strings.TrimSpace(warningText)
if trimmed == "" {
return
}
for _, existing := range *target {
if strings.TrimSpace(existing) == trimmed {
return
}
}
*target = append(*target, trimmed)
}
func defaultCourseImageParseMessage(status model.CourseImageParseDraftStatus, rowCount int) string {
switch status {
case model.CourseImageParseDraftStatusSuccess:
return fmt.Sprintf("已识别 %d 条课程安排,请重点核对周次、星期和节次。", rowCount)
case model.CourseImageParseDraftStatusPartial:
return fmt.Sprintf("已识别 %d 条课程安排,但仍存在不确定字段,请结合 warning 逐项核对。", rowCount)
case model.CourseImageParseDraftStatusReject:
return "图片信息不足,建议重新上传完整、清晰、包含表头和节次栏的总课表截图。"
default:
return "课程表图片识别已完成,请人工核对后再导入。"
}
}

View File

@@ -0,0 +1,224 @@
package service
import (
"context"
"encoding/base64"
"fmt"
"log"
"strings"
"time"
infrallm "github.com/LoveLosita/smartflow/backend/infra/llm"
"github.com/LoveLosita/smartflow/backend/model"
)
// ParseCourseTableImage 使用 Ark SDK Responses 解析课程表图片。
func (ss *CourseService) ParseCourseTableImage(ctx context.Context, req model.CourseImageParseRequest) (*model.CourseImageParseResponse, error) {
if ss == nil || ss.courseImageResponsesClient == nil {
log.Printf(
"[COURSE_PARSE][SERVICE] parser unavailable model_name=%q filename=%q mime=%q bytes=%d",
ss.courseImageModel,
req.Filename,
req.MIMEType,
len(req.ImageBytes),
)
return nil, ErrCourseImageParserUnavailable
}
normalizedReq, err := normalizeCourseImageParseRequest(req, ss.courseImageConfig)
if err != nil {
log.Printf(
"[COURSE_PARSE][SERVICE] request normalization failed filename=%q mime=%q bytes=%d err=%v",
req.Filename,
req.MIMEType,
len(req.ImageBytes),
err,
)
return nil, err
}
log.Printf(
"[COURSE_PARSE][SERVICE] normalized request model_name=%q filename=%q mime=%q bytes=%d max_bytes=%d",
ss.courseImageModel,
normalizedReq.Filename,
normalizedReq.MIMEType,
len(normalizedReq.ImageBytes),
ss.courseImageConfig.MaxImageBytes,
)
messages, base64Chars, promptChars := buildCourseImageParseResponsesMessages(normalizedReq)
startAt := time.Now()
log.Printf(
"[COURSE_PARSE][SERVICE] model invoke start model_name=%q filename=%q mime=%q message_count=%d base64_chars=%d prompt_chars=%d payload_chars_estimate=%d thinking=%s temperature=%.2f max_output_tokens=%d text_format=%s",
ss.courseImageModel,
normalizedReq.Filename,
normalizedReq.MIMEType,
len(messages),
base64Chars,
promptChars,
base64Chars+promptChars+len(strings.TrimSpace(courseImageParseSystemPrompt)),
infrallm.ThinkingModeDisabled,
courseImageParseTemperature,
ss.courseImageConfig.MaxTokens,
"json_object",
)
// 1. 课程表图片识别输出体量大,显式透传 max_output_tokens避免被默认值截断。
// 2. text_format 固定为 json_object降低输出混入解释文本导致解析失败的概率。
// 3. thinking 显式关闭,优先保证课程导入链路稳定性。
draft, rawResult, err := infrallm.GenerateArkResponsesJSON[model.CourseImageParseResponse](ctx, ss.courseImageResponsesClient, messages, infrallm.ArkResponsesOptions{
Temperature: courseImageParseTemperature,
MaxOutputTokens: ss.courseImageConfig.MaxTokens,
Thinking: infrallm.ThinkingModeDisabled,
TextFormat: "json_object",
})
if err != nil {
rawText := ""
rawChars := 0
status := ""
incompleteReason := ""
errorCode := ""
errorMessage := ""
inputTokens := int64(0)
outputTokens := int64(0)
totalTokens := int64(0)
if rawResult != nil {
rawText = strings.TrimSpace(rawResult.Text)
rawChars = len(rawText)
status = strings.TrimSpace(rawResult.Status)
incompleteReason = strings.TrimSpace(rawResult.IncompleteReason)
errorCode = strings.TrimSpace(rawResult.ErrorCode)
errorMessage = strings.TrimSpace(rawResult.ErrorMessage)
if rawResult.Usage != nil {
inputTokens = rawResult.Usage.InputTokens
outputTokens = rawResult.Usage.OutputTokens
totalTokens = rawResult.Usage.TotalTokens
}
}
log.Printf(
"[COURSE_PARSE][SERVICE] model invoke failed model_name=%q filename=%q mime=%q cost_ms=%d err=%v status=%q incomplete_reason=%q error_code=%q error_message=%q input_tokens=%d output_tokens=%d total_tokens=%d raw_chars=%d raw_full=\n%s",
ss.courseImageModel,
normalizedReq.Filename,
normalizedReq.MIMEType,
time.Since(startAt).Milliseconds(),
err,
status,
incompleteReason,
errorCode,
errorMessage,
inputTokens,
outputTokens,
totalTokens,
rawChars,
rawText,
)
if isCourseImageOutputTruncated(rawResult) {
return nil, fmt.Errorf(
"课程表识别输出疑似被 max_output_tokens 截断status=%s incomplete_reason=%s output_tokens=%d max_output_tokens=%d",
status,
incompleteReason,
outputTokens,
ss.courseImageConfig.MaxTokens,
)
}
return nil, err
}
rawText := ""
rawChars := 0
status := ""
incompleteReason := ""
errorCode := ""
errorMessage := ""
inputTokens := int64(0)
outputTokens := int64(0)
totalTokens := int64(0)
if rawResult != nil {
rawText = strings.TrimSpace(rawResult.Text)
rawChars = len(rawText)
status = strings.TrimSpace(rawResult.Status)
incompleteReason = strings.TrimSpace(rawResult.IncompleteReason)
errorCode = strings.TrimSpace(rawResult.ErrorCode)
errorMessage = strings.TrimSpace(rawResult.ErrorMessage)
if rawResult.Usage != nil {
inputTokens = rawResult.Usage.InputTokens
outputTokens = rawResult.Usage.OutputTokens
totalTokens = rawResult.Usage.TotalTokens
}
}
log.Printf(
"[COURSE_PARSE][SERVICE] model invoke success model_name=%q filename=%q mime=%q cost_ms=%d status=%q incomplete_reason=%q error_code=%q error_message=%q input_tokens=%d output_tokens=%d total_tokens=%d raw_chars=%d raw_full=\n%s",
ss.courseImageModel,
normalizedReq.Filename,
normalizedReq.MIMEType,
time.Since(startAt).Milliseconds(),
status,
incompleteReason,
errorCode,
errorMessage,
inputTokens,
outputTokens,
totalTokens,
rawChars,
rawText,
)
normalizedDraft, err := normalizeCourseImageParseResponse(draft)
if err != nil {
log.Printf(
"[COURSE_PARSE][SERVICE] draft normalization failed model_name=%q filename=%q err=%v draft_status=%v row_count=%d",
ss.courseImageModel,
normalizedReq.Filename,
err,
draft.DraftStatus,
len(draft.Rows),
)
return nil, err
}
log.Printf(
"[COURSE_PARSE][SERVICE] draft normalization success model_name=%q filename=%q draft_status=%s rows=%d warnings=%d",
ss.courseImageModel,
normalizedReq.Filename,
normalizedDraft.DraftStatus,
len(normalizedDraft.Rows),
len(normalizedDraft.Warnings),
)
return normalizedDraft, nil
}
func buildCourseImageParseResponsesMessages(req *model.CourseImageParseRequest) ([]infrallm.ArkResponsesMessage, int, int) {
userPrompt := fmt.Sprintf(courseImageParseUserPromptTemplate, req.Filename, req.MIMEType)
base64Data := base64.StdEncoding.EncodeToString(req.ImageBytes)
imageDataURL := fmt.Sprintf("data:%s;base64,%s", req.MIMEType, base64Data)
messages := []infrallm.ArkResponsesMessage{
{
Role: "system",
Text: strings.TrimSpace(courseImageParseSystemPrompt),
},
{
Role: "user",
Text: strings.TrimSpace(userPrompt),
ImageURL: imageDataURL,
ImageDetail: "high",
},
}
return messages, len(base64Data), len(strings.TrimSpace(userPrompt))
}
func isCourseImageOutputTruncated(rawResult *infrallm.ArkResponsesResult) bool {
if rawResult == nil {
return false
}
reason := strings.ToLower(strings.TrimSpace(rawResult.IncompleteReason))
if strings.Contains(reason, "max_output_tokens") ||
strings.Contains(reason, "max_tokens") ||
strings.Contains(reason, "length") {
return true
}
return strings.EqualFold(strings.TrimSpace(rawResult.Status), "incomplete") && reason == ""
}

View File

@@ -0,0 +1,59 @@
package service
const courseImageParseSystemPrompt = `
你是 SmartFlow 的“总课表图片识别器”。你的唯一任务是读取用户上传的总课表图片,输出结构化 JSON 草稿,供前端人工核对后再导入系统。
必须遵守以下规则:
1. 只能输出一个 JSON 对象,禁止输出 Markdown、代码块、解释文字或额外前后缀。
2. 顶层 JSON 结构必须是:
{
"draft_status": "success | partial | reject",
"message": "字符串",
"warnings": ["字符串"],
"rows": [
{
"row_id": "字符串,可为空",
"course_name": "字符串",
"location": "字符串",
"is_allow_tasks": false,
"start_week": 1,
"end_week": 16,
"day_of_week": 1,
"start_section": 1,
"end_section": 2,
"week_type": "all | odd | even",
"confidence": 0.92,
"raw_text": "原图中对应的近似文本",
"row_warnings": ["字符串"]
}
]
}
3. rows 中一行只表达一个“课程安排片段”,不要把同一门课的多个时间段强行合并成一行。
4. is_allow_tasks 无法从课表图片稳定识别时,一律返回 false不要自行猜测。
5. 若图片完整且大部分字段明确,可返回 success。
6. 若图片可识别出部分行,但存在裁切、模糊、遮挡、单双周不清晰、节次/周次不确定等问题,返回 partial。
7. 若图片严重不完整、分辨率过低、主体不是课表、无法可靠识别,返回 reject同时 rows 置为空数组。
8. 不要编造信息。看不清的数值字段请返回 null并在 row_warnings 或 warnings 中明确说明原因。
9. week_type 只能是:
- all每周/未标注单双周
- odd单周
- even双周
10. day_of_week 使用 1-7 表示周一到周日。
11. start_section/end_section 使用原子节次编号,例如 1-2 节应输出 start_section=1, end_section=2。
12. confidence 取 0 到 1 之间的小数;不确定时可以偏保守。
13. 如果 rows 不为空,优先保证“周次、星期、节次”准确,地点可为空字符串。
14. 当图片信息不足时,应明确拒绝或降级为 partial而不是强行补全。
15. 填写json中course_name时严格按照截图的课程名称来。例如有的课可能既有本体又有实验课这算是两门不同的课。
16. 周信息是可能出现中断的例如一节课可能是第1周和第6-12周这是正常的课程安排请不要擅自更改。
`
const courseImageParseUserPromptTemplate = `
请识别这张总课表图片,并严格按照约定 JSON 输出草稿。
补充约束:
1. 文件名:%s
2. MIME 类型:%s
3. 这是一张供学生核对的“导入草稿”,不是最终真值;不确定就留空或写 warning。
4. 如果图片右侧、底部、表头、周次栏、节次栏有缺失,请优先返回 partial 或 reject。
5. rows 里尽量保留 raw_text方便前端逐行回显核对。
`

View File

@@ -0,0 +1,540 @@
# 课表图片识别与正式导入前端对接说明
## 目标
这套流程拆成两步:
1. 先调用识图接口,把“总课表图片”转成前端可编辑的草稿表格数据。
2. 用户确认无误后,再调用正式导入接口,把课程写入后端日程表。
这样做的好处是:
- 识图接口保持无状态,只负责“图片 -> 草稿”
- 前端完全掌握编辑态,不依赖后端保存临时草稿
- 正式落库仍统一走现有 `/api/v1/course/import`
## 总体流程
推荐前端流程:
1. 用户上传总课表图片。
2. 前端调用 `/api/v1/course/parse-image` 获取识别草稿。
3. 前端把 `rows` 存在本地页面状态中,渲染成可编辑表格。
4. 用户修改并确认所有行数据。
5. 前端把表格行数据组装成 `/api/v1/course/import` 所需结构。
6. 前端携带 `X-Idempotency-Key` 调用正式导入接口。
7. 导入成功后刷新课表页;若有冲突则提示用户处理。
## 一、识图接口
### 1.1 接口定义
- 方法:`POST`
- 路径:`/api/v1/course/parse-image`
- 鉴权:需要登录态
- Content-Type`multipart/form-data`
- 文件字段名:`image`
说明:
- 该接口不需要 `X-Idempotency-Key`
- 该接口不落库,只返回草稿
### 1.2 请求示例
```ts
async function parseCourseImage(file: File, token: string) {
const formData = new FormData()
formData.append('image', file)
const response = await fetch('/api/v1/course/parse-image', {
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
},
body: formData,
})
const result = await response.json()
if (!response.ok) {
throw new Error(result?.info || '课表图片识别失败')
}
return result.data
}
```
### 1.3 成功响应
外层仍复用项目统一响应结构:
```json
{
"status": "10000",
"info": "success",
"data": {
"draft_status": "success",
"message": "已识别 12 条课程安排,请重点核对周次、星期和节次。",
"warnings": [
"图片右下角疑似被裁切,请重点检查最后几列课程。"
],
"rows": [
{
"row_id": "row_001",
"course_name": "高等数学",
"location": "教一203",
"is_allow_tasks": false,
"start_week": 1,
"end_week": 16,
"day_of_week": 1,
"start_section": 1,
"end_section": 2,
"week_type": "all",
"confidence": 0.92,
"raw_text": "高等数学 1-16周 周一1-2节 教一203",
"row_warnings": []
}
]
}
}
```
### 1.4 `draft_status` 语义
- `success`
说明图片整体较完整,前端可直接进入表格核对。
- `partial`
说明后端识别到了部分安排,但仍存在不确定字段。前端仍可进入表格核对,但应醒目展示 `warnings``row_warnings`
- `reject`
说明图片不适合继续使用,例如裁切严重、模糊、主体不是课表等。此时 `rows` 为空数组,前端应提示用户重新上传,而不是进入编辑表格。
### 1.5 行字段说明
每一行表示一个“课程安排片段”,不是一门课的完整聚合对象。
- `row_id`
前端表格的稳定 key。即使用户编辑字段也建议保留该 key。
- `course_name`
课程名。`success` 场景下通常应有值;`partial` 场景下可能为空,前端要允许人工补齐。
- `location`
地点。允许为空字符串。
- `is_allow_tasks`
当前后端默认给 `false`。因为该字段无法从课表图片稳定识别,建议前端提供开关让用户手改。
- `start_week` / `end_week`
周次范围。可能为 `null`,前端应允许编辑。
- `day_of_week`
星期,取值 `1-7`,分别对应周一到周日。可能为 `null`
- `start_section` / `end_section`
原子节次编号,例如 `1-2 节` 应表示为 `1``2`。可能为 `null`
- `week_type`
仅允许以下三种值:
- `all`
- `odd`
- `even`
- `confidence`
0 到 1 之间的小数,用于前端做低置信度高亮。建议把 `<0.75` 的行重点提示用户复核。
- `raw_text`
模型从图片中抽出的近似原文,建议在“展开详情”或 tooltip 中展示,帮助用户比对。
- `row_warnings`
当前行的局部问题提示,例如“单双周不清晰”“教室模糊”等。
### 1.6 前端表格建议列
建议首版直接用可编辑表格,不必先做课表画布。
推荐列:
- 课程名
- 地点
- 起始周
- 结束周
- 星期
- 开始节次
- 结束节次
- 周类型
- 允许嵌入任务
- 置信度
- 原文
- 行级警告
### 1.7 识图接口常见失败响应
- `50003``course image parser is not configured`
后端尚未配置多模态模型。
- `40064``course image too large`
图片超过后端配置的字节上限。
- `40065``unsupported course image format`
当前仅支持 `jpg/jpeg/png/webp`
- `40066``course image is empty`
上传文件为空。
### 1.8 前端交互建议
- `draft_status=reject` 时,不进入表格页,直接提示重新上传。
- `draft_status=partial` 时,允许进入表格页,但默认展开 warning 区域。
- 当某行 `confidence < 0.75``row_warnings` 非空时,给该行加醒目边框或标签。
## 二、正式导入接口
### 2.1 接口定义
- 方法:`POST`
- 路径:`/api/v1/course/import`
- 鉴权:需要登录态
- Content-Type`application/json`
- 必传请求头:`X-Idempotency-Key`
说明:
- 正式导入接口会真正写库
- 该接口已经挂了幂等中间件,前端必须传 `X-Idempotency-Key`
### 2.2 请求体结构
后端正式导入接口需要的是:
```json
{
"courses": [
{
"course_name": "高等数学",
"location": "教一203",
"is_allow_tasks": false,
"arrangements": [
{
"start_week": 1,
"end_week": 16,
"day_of_week": 1,
"start_section": 1,
"end_section": 2,
"week_type": "all"
}
]
}
]
}
```
字段语义:
- `course_name`: 课程名
- `location`: 地点
- `is_allow_tasks`: 是否允许该课程时段嵌入任务
- `arrangements`: 该课程的所有上课安排
### 2.3 前端如何从草稿行表组装正式导入 payload
推荐规则:
1. 先过滤掉仍未补齐必填字段的行。
2.`course_name + location + is_allow_tasks` 作为分组键。
3. 同组行合并为同一个 `course`
4. 每一行转成该 `course` 下的一个 `arrangements` 项。
推荐的前端类型:
```ts
type CourseDraftRow = {
row_id: string
course_name: string
location: string
is_allow_tasks: boolean
start_week: number | null
end_week: number | null
day_of_week: number | null
start_section: number | null
end_section: number | null
week_type: 'all' | 'odd' | 'even' | ''
confidence: number
raw_text: string
row_warnings: string[]
}
type CourseImportPayload = {
courses: Array<{
course_name: string
location: string
is_allow_tasks: boolean
arrangements: Array<{
start_week: number
end_week: number
day_of_week: number
start_section: number
end_section: number
week_type: 'all' | 'odd' | 'even'
}>
}>
}
```
组装示例:
```ts
function buildCourseImportPayload(rows: CourseDraftRow[]): CourseImportPayload {
const validRows = rows.filter((row) =>
row.course_name.trim() &&
row.start_week != null &&
row.end_week != null &&
row.day_of_week != null &&
row.start_section != null &&
row.end_section != null &&
(row.week_type === 'all' || row.week_type === 'odd' || row.week_type === 'even'),
)
const grouped = new Map<string, CourseImportPayload['courses'][number]>()
for (const row of validRows) {
const key = `${row.course_name}__${row.location}__${row.is_allow_tasks}`
const arrangement = {
start_week: row.start_week!,
end_week: row.end_week!,
day_of_week: row.day_of_week!,
start_section: row.start_section!,
end_section: row.end_section!,
week_type: row.week_type as 'all' | 'odd' | 'even',
}
if (!grouped.has(key)) {
grouped.set(key, {
course_name: row.course_name.trim(),
location: row.location.trim(),
is_allow_tasks: row.is_allow_tasks,
arrangements: [arrangement],
})
continue
}
grouped.get(key)!.arrangements.push(arrangement)
}
return {
courses: Array.from(grouped.values()),
}
}
```
### 2.4 调用正式导入接口示例
```ts
function createIdempotencyKey(prefix = 'course-import') {
return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`
}
async function importCourses(
payload: CourseImportPayload,
token: string,
idempotencyKey = createIdempotencyKey(),
) {
const response = await fetch('/api/v1/course/import', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
'X-Idempotency-Key': idempotencyKey,
},
body: JSON.stringify(payload),
})
const result = await response.json()
if (!response.ok) {
const error = new Error(result?.info || '课程导入失败')
;(error as any).status = response.status
;(error as any).payload = result
throw error
}
return result
}
```
### 2.5 正式导入成功响应
```json
{
"status": "10000",
"info": "success"
}
```
## 三、前端在正式导入前应做的本地校验
建议在调用 `/api/v1/course/import` 前先做一次本地校验,避免把明显不完整的数据提交给后端。
建议校验项:
1. `course_name` 非空
2. `start_week` / `end_week` 非空,且 `1 <= start_week <= end_week <= 24`
3. `day_of_week``1-7`
4. `start_section` / `end_section` 非空,且 `1 <= start_section <= end_section <= 12`
5. `week_type` 只能是 `all | odd | even`
如果校验失败,建议直接在表格中标红,不要发请求。
## 四、正式导入接口的常见失败响应
### 4.1 缺少幂等键
HTTP 状态:`400`
```json
{
"status": "40037",
"info": "missing idempotency key"
}
```
前端处理建议:
- 检查是否传了 `X-Idempotency-Key`
- 每次“点击确认导入”生成一个新的 key
- 不要在一次导入流程里反复变化同一个 key
### 4.2 请求处理中
HTTP 状态:`409`
```json
{
"status": "40038",
"info": "request is processing, please do not repeat click"
}
```
前端处理建议:
- 导入按钮进入 loading 态
- 阻止用户连续点击
- 若收到该错误,提示“正在导入,请勿重复提交”
### 4.3 课程结构不合法
HTTP 状态:`400`
```json
{
"status": "40019",
"info": "wrong course info"
}
```
前端处理建议:
- 说明当前编辑结果不满足导入约束
- 引导用户重点检查周次、星期、节次、周类型
### 4.4 重复导入课程
HTTP 状态:`400`
```json
{
"status": "40029",
"info": "insert course twice"
}
```
前端处理建议:
- 提示用户当前课程可能已经导入过
- 可提供“返回课表页查看现有课程”的入口
### 4.5 与已有非课程日程冲突
HTTP 状态:`409`
响应示例:
```json
{
"status": "40026",
"info": "schedule conflict",
"data": [
{
"event_id": 101,
"name": "实验报告",
"location": "图书馆",
"day_of_week": 1,
"week": 3,
"sections": [1, 2],
"start_section": 1,
"end_section": 2,
"type": "task",
"embedded_tasks": []
}
]
}
```
前端处理建议:
- 不要把冲突吞掉
- 可以弹窗列出冲突项
- 提示用户先处理已有日程,再重新导入
## 五、推荐的前端调用顺序
推荐把整个导入流程写成这三个阶段:
### 阶段一:识图
- 上传图片
- 调用 `/course/parse-image`
-`draft_status=reject`,直接结束
-`success``partial`,进入草稿编辑页
### 阶段二:草稿编辑
- 在本地状态中维护 `rows`
- 支持用户改课程名、周次、节次、地点、周类型、`is_allow_tasks`
- 在页面上展示 `warnings``row_warnings``confidence`
### 阶段三:正式导入
- 先做本地校验
- 再调用 `buildCourseImportPayload(rows)`
- 生成 `X-Idempotency-Key`
- 调用 `/course/import`
- 成功后提示并刷新课表
## 六、可直接复用的页面状态建议
```ts
type CourseImageImportPageState = {
imageFile: File | null
parseLoading: boolean
importLoading: boolean
draftStatus: 'success' | 'partial' | 'reject' | null
draftMessage: string
warnings: string[]
rows: CourseDraftRow[]
}
```
## 七、配置提醒
后端需要额外配置一个多模态模型:
```yaml
courseImport:
visionModel: "你的多模态模型名"
maxImageBytes: 5242880
maxTokens: 8192
```
`visionModel` 留空,则主服务仍可启动,但 `/course/parse-image` 会返回“识图模型未配置”。
若后端日志出现 `finish_reason="length"`,说明模型输出被长度上限截断,应优先增大 `courseImport.maxTokens`

View File

@@ -1,3 +1,4 @@
import axios from 'axios'
import http from '@/api/http'
import type { ApiResponse, PlainResponse } from '@/types/api'
import type {
@@ -7,6 +8,9 @@ import type {
TaskClassCreatePayload,
TaskClassDetail,
TaskClassListItem,
CourseDraftRow,
CourseImportPayload,
CourseImageParseResponse,
} from '@/types/schedule'
import { extractErrorMessage } from '@/utils/http'
import { createIdempotencyKey } from '@/utils/idempotency'
@@ -147,3 +151,33 @@ export async function deleteTaskClassItem(taskItemId: number, idempotencyKey = c
throw new Error(extractErrorMessage(error, '\u5220\u9664\u4efb\u52a1\u5757\u5931\u8d25\uff0c\u8bf7\u7a0d\u540e\u91cd\u8bd5'))
}
}
export async function parseCourseImage(file: File, signal?: AbortSignal) {
try {
const formData = new FormData()
formData.append('image', file)
const response = await http.post<ApiResponse<CourseImageParseResponse>>('/course/parse-image', formData, {
timeout: 300000,
signal,
})
return response.data.data
} catch (error) {
if (axios.isCancel(error)) {
throw new Error('\u8bc6\u522b\u5df2\u53d6\u6d88')
}
throw new Error(extractErrorMessage(error, '\u8bfe\u8868\u56fe\u7247\u8bc6\u522b\u5931\u8d25\uff0c\u8bf7\u7a0d\u540e\u91cd\u8bd5'))
}
}
export async function importCourses(payload: CourseImportPayload, idempotencyKey = createIdempotencyKey('course-import')) {
try {
const response = await http.post<PlainResponse>('/course/import', payload, {
headers: {
'X-Idempotency-Key': idempotencyKey,
},
})
return response.data
} catch (error) {
throw new Error(extractErrorMessage(error, '\u8bfe\u7a0b\u5bfc\u5165\u5931\u8d25\uff0c\u8bf7\u7a0d\u540e\u91cd\u8bd5'))
}
}

View File

@@ -1189,7 +1189,9 @@ function shouldShowDisplayReasoningBox(dm: DisplayMessage): boolean {
}
function shouldShowDisplayAnsweringIndicator(dm: DisplayMessage): boolean {
return isDisplayStreaming(dm) && dm.sources.every(m => thinkingMessageMap[m.id] !== true)
return isDisplayStreaming(dm) &&
dm.sources.every(m => thinkingMessageMap[m.id] !== true) &&
!dm.content.trim()
}
function isDisplayReasoningCollapsed(dm: DisplayMessage): boolean {
@@ -2714,10 +2716,8 @@ onBeforeUnmount(() => {
v-html="renderMessageMarkdown(block.text)"
/>
<div v-else class="chat-message__streaming chat-message__streaming--reasoning">
<div class="typing-indicator">
<span />
<span />
<span />
<div class="thinking-indicator">
<span class="thinking-indicator__text">正在思考</span>
</div>
</div>
</div>
@@ -2735,10 +2735,8 @@ onBeforeUnmount(() => {
</template>
<div v-else-if="block.type === 'content_indicator'" class="assistant-timeline__answering-indicator">
<div class="typing-indicator">
<span />
<span />
<span />
<div class="thinking-indicator">
<span class="thinking-indicator__text">正在思考</span>
</div>
</div>
</div>
@@ -4652,26 +4650,33 @@ onBeforeUnmount(() => {
border-color: rgba(15, 23, 42, 0.08);
}
.typing-indicator {
display: flex;
.thinking-indicator {
display: inline-flex;
align-items: center;
gap: 6px;
}
.typing-indicator span {
width: 8px;
height: 8px;
border-radius: 999px;
background: #86a9dd;
animation: typing-bounce 1.2s ease-in-out infinite;
.thinking-indicator__text {
font-size: 15px;
font-weight: 600;
color: #64748b;
background: linear-gradient(
90deg,
#64748b 0%,
#64748b 25%,
#e2e8f0 50%,
#64748b 75%,
#64748b 100%
);
background-size: 200% 100%;
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
animation: thinking-shimmer 2s infinite linear;
}
.typing-indicator span:nth-child(2) {
animation-delay: 0.12s;
}
.typing-indicator span:nth-child(3) {
animation-delay: 0.24s;
@keyframes thinking-shimmer {
from { background-position: 200% 0; }
to { background-position: 0% 0; }
}
@keyframes confirm-card-enter {
@@ -4679,11 +4684,6 @@ onBeforeUnmount(() => {
100% { opacity: 1; transform: translateY(0) scale(1); }
}
@keyframes typing-bounce {
0%, 80%, 100% { transform: translateY(0); opacity: 0.5; }
40% { transform: translateY(-4px); opacity: 1; }
}
@keyframes pulse-dot {
0% { box-shadow: 0 0 0 0 rgba(90, 152, 255, 0.34); }
70% { box-shadow: 0 0 0 8px rgba(90, 152, 255, 0); }

View File

@@ -0,0 +1,927 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { parseCourseImage, importCourses } from '@/api/scheduleCenter'
import type { CourseDraftRow, CourseImportPayload } from '@/types/schedule'
const props = defineProps<{
modelValue: boolean
}>()
const emit = defineEmits<{
(e: 'update:modelValue', value: boolean): void
(e: 'success'): void
}>()
const visible = computed({
get: () => props.modelValue,
set: (val) => emit('update:modelValue', val)
})
// 状态管理
const step = ref<'upload' | 'edit'>('upload')
const imageFile = ref<File | null>(null)
const imagePreview = ref('')
const parseLoading = ref(false)
const importLoading = ref(false)
const abortController = ref<AbortController | null>(null)
const draftStatus = ref<'success' | 'partial' | 'reject' | null>(null)
const draftMessage = ref('')
const warnings = ref<string[]>([])
const rows = ref<CourseDraftRow[]>([])
// 新增课程相关的状态
const addDialogVisible = ref(false)
const newRowTemplate = (): CourseDraftRow => ({
row_id: 'manual_' + Date.now(),
course_name: '',
location: '',
start_week: 1,
end_week: 16,
day_of_week: 1,
start_section: 1,
end_section: 2,
week_type: 'all',
is_allow_tasks: false,
confidence: 1,
raw_text: '手动添加',
row_warnings: []
})
const newRowData = ref<CourseDraftRow>(newRowTemplate())
// 拖拽相关状态
const draggingIndex = ref<number | null>(null)
// 模拟示例图 URL
const exampleImageUrl = 'https://dl2.lecspace.com/SmartFlow-Agent/%E8%AF%BE%E8%A1%A8%E4%B8%8A%E4%BC%A0%E7%A4%BA%E4%BE%8B%E5%9B%BE%E7%89%87.png'
const handleFileChange = (e: Event) => {
const input = e.target as HTMLInputElement
if (input.files && input.files[0]) {
const file = input.files[0]
// 简单校验格式
if (!['image/jpeg', 'image/png', 'image/webp'].includes(file.type)) {
ElMessage.warning('仅支持 JPG/PNG/WEBP 格式的图片')
return
}
imageFile.value = file
imagePreview.value = URL.createObjectURL(file)
}
}
const startRecognition = async () => {
if (!imageFile.value) return
parseLoading.value = true
abortController.value = new AbortController()
try {
const data = await parseCourseImage(imageFile.value, abortController.value.signal)
if (data.draft_status === 'reject') {
await ElMessageBox.alert(data.message || '图片识别失败,请重新上传清晰的课表图片。', '识别被拒绝', {
type: 'error',
confirmButtonText: '我知道了'
})
return
}
draftStatus.value = data.draft_status
draftMessage.value = data.message
warnings.value = data.warnings
// 对识别出的行进行排序:先按课程名聚类,再按起始周从小到大排列
rows.value = [...data.rows]
sortRows()
step.value = 'edit'
} catch (error: any) {
if (error.message !== '识别已取消') {
ElMessage.error(error.message || '识别过程中出现错误')
}
} finally {
parseLoading.value = false
abortController.value = null
}
}
const stopRecognition = () => {
if (abortController.value) {
abortController.value.abort()
ElMessage.info('识别已手动停止')
}
}
const sortRows = () => {
rows.value.sort((a, b) => {
const nameCompare = (a.course_name || '').localeCompare(b.course_name || '')
if (nameCompare !== 0) return nameCompare
return (a.start_week || 0) - (b.start_week || 0)
})
}
const handleOpenAddDialog = () => {
newRowData.value = newRowTemplate()
addDialogVisible.value = true
}
const handleConfirmAddRow = () => {
if (!newRowData.value.course_name.trim()) {
ElMessage.warning('课程名不能为空')
return
}
rows.value.push({ ...newRowData.value })
sortRows()
addDialogVisible.value = false
ElMessage.success('课程已添加并自动排序')
}
const handleRemoveRow = async (index: number) => {
try {
await ElMessageBox.confirm('确定要删除这一行课程安排吗?', '确认删除', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
})
rows.value.splice(index, 1)
ElMessage.success('已删除')
} catch {
// 用户取消删除
}
}
// 拖拽逻辑实现
const onDragStart = (index: number) => {
draggingIndex.value = index
}
const onDragOver = (e: DragEvent) => {
e.preventDefault() // 允许放置
}
const onDrop = (index: number) => {
if (draggingIndex.value === null || draggingIndex.value === index) return
const draggedRow = rows.value[draggingIndex.value]
rows.value.splice(draggingIndex.value, 1)
rows.value.splice(index, 0, draggedRow)
draggingIndex.value = null
}
const reset = () => {
step.value = 'upload'
imageFile.value = null
imagePreview.value = ''
rows.value = []
draftStatus.value = null
warnings.value = []
}
// 校验与提交逻辑
const validateRows = () => {
const errors: string[] = []
rows.value.forEach((row, index) => {
if (!row.course_name.trim()) errors.push(`${index + 1} 行:课程名不能为空`)
if (row.start_week == null || row.end_week == null || row.start_week < 1 || row.end_week > 24 || row.start_week > row.end_week) {
errors.push(`${index + 1} 行:周次范围非法 (1-24)`)
}
if (row.day_of_week == null || row.day_of_week < 1 || row.day_of_week > 7) {
errors.push(`${index + 1} 行:星期选择非法`)
}
if (row.start_section == null || row.end_section == null || row.start_section < 1 || row.end_section > 12 || row.start_section > row.end_section) {
errors.push(`${index + 1} 行:节次范围非法 (1-12)`)
}
if (!['all', 'odd', 'even'].includes(row.week_type)) {
errors.push(`${index + 1} 行:周类型非法`)
}
})
return errors
}
const handleImport = async () => {
const errors = validateRows()
if (errors.length > 0) {
ElMessage.error({
message: errors[0], // 只显示第一个错误
duration: 3000
})
return
}
try {
await ElMessageBox.confirm('确定要导入识别出的课程吗?这可能会覆盖或冲突现有日程。', '确认导入', {
confirmButtonText: '确定导入',
cancelButtonText: '我再想想',
type: 'info'
})
importLoading.value = true
const payload = buildPayload(rows.value)
await importCourses(payload)
ElMessage.success('课程导入成功!')
visible.value = false
emit('success')
} catch (error: any) {
if (error !== 'cancel') {
ElMessage.error(error.message || '导入失败')
}
} finally {
importLoading.value = false
}
}
const buildPayload = (draftRows: CourseDraftRow[]): CourseImportPayload => {
const grouped = new Map<string, CourseImportPayload['courses'][number]>()
for (const row of draftRows) {
// 恢复原来的聚合逻辑:以 课程名 + 地点 + 是否允许任务 为主键
const key = `${row.course_name.trim()}__${row.location.trim()}__${row.is_allow_tasks}`
const arrangement = {
start_week: row.start_week!,
end_week: row.end_week!,
day_of_week: row.day_of_week!,
start_section: row.start_section!,
end_section: row.end_section!,
week_type: row.week_type as 'all' | 'odd' | 'even'
}
if (!grouped.has(key)) {
grouped.set(key, {
course_name: row.course_name.trim(),
location: row.location.trim(),
is_allow_tasks: row.is_allow_tasks,
arrangements: [arrangement]
})
} else {
grouped.get(key)!.arrangements.push(arrangement)
}
}
return { courses: Array.from(grouped.values()) }
}
const getRowStyle = ({ row }: { row: CourseDraftRow }) => {
if (row.confidence < 0.75 || row.row_warnings.length > 0) {
return { backgroundColor: 'rgba(245, 158, 11, 0.05)' }
}
return {}
}
</script>
<template>
<el-dialog
v-model="visible"
title="导入课表"
width="98%"
top="2vh"
class="import-dialog"
:close-on-click-modal="false"
destroy-on-close
@closed="reset"
>
<!-- 阶段一上传识别 -->
<div v-if="step === 'upload'" class="upload-stage">
<div class="stage-header">
<h3>1. 上传课表截图</h3>
<p>请上传完整的课表图片AI 将自动识别课程信息</p>
<div class="upload-tips">
<span class="tip-item"> 预计识别时长约 2 分钟</span>
<span class="tip-item">🤖 结果由 AI 生成请务必仔细审核</span>
</div>
</div>
<div class="upload-container">
<div class="example-box">
<span class="badge">截图示例</span>
<img :src="exampleImageUrl" alt="Example" class="example-img" />
</div>
<div class="upload-box" :class="{ 'has-file': !!imageFile }">
<input
type="file"
id="course-upload-input"
accept="image/*"
hidden
@change="handleFileChange"
/>
<label for="course-upload-input" class="upload-trigger">
<div v-if="!imagePreview" class="placeholder">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4M17 8l-5-5-5 5M12 3v12"/>
</svg>
<span>点击选择或拖拽图片</span>
</div>
<img v-else :src="imagePreview" alt="Preview" class="preview-img" />
</label>
</div>
</div>
<div class="stage-footer">
<button
v-if="parseLoading"
class="btn-ghost"
@click="stopRecognition"
>
停止识别
</button>
<button
class="btn-primary"
:disabled="!imageFile || parseLoading"
@click="startRecognition"
>
<span v-if="parseLoading" class="spinner"></span>
{{ parseLoading ? '正在识别中...' : '开始识别' }}
</button>
</div>
</div>
<!-- 阶段二核对修改 -->
<div v-else class="edit-stage">
<div class="stage-header">
<div class="header-left">
<h3>2. 核对识别结果</h3>
<p>{{ draftMessage }}</p>
</div>
<div class="header-actions">
<button class="btn-ghost btn-sm" @click="handleOpenAddDialog">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width:16px;height:16px;margin-right:4px;">
<path d="M12 5v14M5 12h14"/>
</svg>
新增课程行
</button>
</div>
<div v-if="warnings.length > 0" class="warning-banner">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="w-icon">
<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0zM12 9v4M12 17h.01"/>
</svg>
<div class="w-list">
<div v-for="(w, i) in warnings" :key="i" class="w-item">{{ w }}</div>
</div>
</div>
</div>
<div class="table-container">
<el-table
:data="rows"
style="width: 100%"
:row-style="getRowStyle"
max-height="500px"
class="flat-table"
>
<el-table-column width="50" align="center">
<template #default="{ $index }">
<div
class="drag-handle"
draggable="true"
@dragstart="onDragStart($index)"
@dragover="onDragOver"
@drop="onDrop($index)"
>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M4 8h16M4 16h16"/>
</svg>
</div>
</template>
</el-table-column>
<el-table-column label="课程名" min-width="150">
<template #default="{ row }">
<el-input v-model="row.course_name" placeholder="请输入" />
</template>
</el-table-column>
<el-table-column label="地点" min-width="120">
<template #default="{ row }">
<el-input v-model="row.location" placeholder="地点" />
</template>
</el-table-column>
<el-table-column label="周次" width="180">
<template #default="{ row }">
<div class="week-range">
<el-input-number v-model="row.start_week" :min="1" :max="24" controls-position="right" size="small" />
<span>-</span>
<el-input-number v-model="row.end_week" :min="1" :max="24" controls-position="right" size="small" />
</div>
</template>
</el-table-column>
<el-table-column label="星期" width="100">
<template #default="{ row }">
<el-select v-model="row.day_of_week" size="small">
<el-option v-for="i in 7" :key="i" :label="'周' + ['一','二','三','四','五','六','日'][i-1]" :value="i" />
</el-select>
</template>
</el-table-column>
<el-table-column label="节次" width="160">
<template #default="{ row }">
<div class="section-range">
<el-input-number v-model="row.start_section" :min="1" :max="12" controls-position="right" size="small" />
<span>-</span>
<el-input-number v-model="row.end_section" :min="1" :max="12" controls-position="right" size="small" />
</div>
</template>
</el-table-column>
<el-table-column label="类型" width="100">
<template #default="{ row }">
<el-select v-model="row.week_type" size="small">
<el-option label="每周" value="all" />
<el-option label="单周" value="odd" />
<el-option label="双周" value="even" />
</el-select>
</template>
</el-table-column>
<el-table-column label="允许嵌入" width="100" align="center">
<template #default="{ row }">
<el-switch v-model="row.is_allow_tasks" />
</template>
</el-table-column>
<el-table-column label="状态" width="100">
<template #default="{ row }">
<div v-if="row.row_warnings.length > 0" class="row-warning-dot">
<el-tooltip :content="row.row_warnings.join('; ')" placement="top">
<span class="warning-icon"></span>
</el-tooltip>
</div>
<div v-else-if="row.confidence < 0.75" class="row-warning-dot">
<el-tooltip content="置信度较低,请人工核对" placement="top">
<span class="info-icon">💡</span>
</el-tooltip>
</div>
<span v-else class="success-dot"></span>
</template>
</el-table-column>
<el-table-column label="操作" width="80" align="center" fixed="right">
<template #default="{ $index }">
<button class="btn-icon-danger" @click="handleRemoveRow($index)">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M3 6h18M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>
</svg>
</button>
</template>
</el-table-column>
</el-table>
</div>
<div class="stage-footer">
<button class="btn-ghost" @click="step = 'upload'">重新上传</button>
<button
class="btn-primary"
:disabled="importLoading"
@click="handleImport"
>
<span v-if="importLoading" class="spinner"></span>
{{ importLoading ? '正在导入...' : '导入课程' }}
</button>
</div>
</div>
<!-- 新增课程子弹窗 -->
<el-dialog
v-model="addDialogVisible"
title="手动新增课程"
width="500px"
append-to-body
class="sub-dialog"
>
<div class="add-row-form">
<div class="form-item">
<label>课程名</label>
<el-input v-model="newRowData.course_name" placeholder="例如:高等数学" />
</div>
<div class="form-item">
<label>地点</label>
<el-input v-model="newRowData.location" placeholder="例如教一203" />
</div>
<div class="form-row">
<div class="form-item">
<label>周次范围</label>
<div class="range-inputs">
<el-input-number v-model="newRowData.start_week" :min="1" :max="24" controls-position="right" />
<span></span>
<el-input-number v-model="newRowData.end_week" :min="1" :max="24" controls-position="right" />
</div>
</div>
</div>
<div class="form-row">
<div class="form-item">
<label>星期</label>
<el-select v-model="newRowData.day_of_week" style="width: 100%">
<el-option v-for="i in 7" :key="i" :label="'周' + ['一','二','三','四','五','六','日'][i-1]" :value="i" />
</el-select>
</div>
<div class="form-item">
<label>周类型</label>
<el-select v-model="newRowData.week_type" style="width: 100%">
<el-option label="每周" value="all" />
<el-option label="单周" value="odd" />
<el-option label="双周" value="even" />
</el-select>
</div>
</div>
<div class="form-item">
<label>节次范围</label>
<div class="range-inputs">
<el-input-number v-model="newRowData.start_section" :min="1" :max="12" controls-position="right" />
<span></span>
<el-input-number v-model="newRowData.end_section" :min="1" :max="12" controls-position="right" />
</div>
</div>
<div class="form-item flex-row">
<label>允许在该时段嵌入任务</label>
<el-switch v-model="newRowData.is_allow_tasks" />
</div>
</div>
<template #footer>
<div class="dialog-footer">
<button class="btn-ghost" @click="addDialogVisible = false">取消</button>
<button class="btn-primary" @click="handleConfirmAddRow">确定添加</button>
</div>
</template>
</el-dialog>
</el-dialog>
</template>
<style scoped>
.import-dialog :deep(.el-dialog) {
border-radius: 24px;
overflow: hidden;
box-shadow: 0 20px 60px rgba(15, 23, 42, 0.15);
}
.import-dialog :deep(.el-dialog__header) {
padding: 24px 24px 0;
margin-right: 0;
}
.import-dialog :deep(.el-dialog__title) {
font-weight: 800;
color: #1e293b;
font-size: 20px;
}
.stage-header {
padding: 0 24px 20px;
}
.stage-header h3 {
margin: 0 0 6px;
font-size: 18px;
color: #1e293b;
}
.stage-header p {
margin: 0;
font-size: 14px;
color: #64748b;
}
.upload-tips {
margin-top: 12px;
display: flex;
gap: 16px;
}
.tip-item {
font-size: 12px;
color: #94a3b8;
display: flex;
align-items: center;
gap: 4px;
background: #f8fafc;
padding: 4px 10px;
border-radius: 6px;
border: 1px solid #f1f5f9;
}
.upload-container {
padding: 0 24px;
display: grid;
grid-template-columns: 1fr 1fr;
gap: 24px;
}
.example-box {
position: relative;
background: #f8fafc;
border-radius: 16px;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
border: 1px dashed #e2e8f0;
height: 550px;
}
.example-box .badge {
position: absolute;
top: 12px;
left: 12px;
background: rgba(15, 23, 42, 0.6);
color: white;
padding: 4px 10px;
border-radius: 6px;
font-size: 12px;
backdrop-filter: blur(4px);
}
.example-img {
max-width: 100%;
max-height: 100%;
object-fit: contain;
opacity: 1;
}
.upload-box {
background: #ffffff;
border: 2px dashed #e2e8f0;
border-radius: 16px;
transition: all 0.2s ease;
cursor: pointer;
overflow: hidden;
height: 550px;
}
.upload-box:hover {
border-color: #3b82f6;
background: #f0f7ff;
}
.upload-box.has-file {
border-style: solid;
}
.upload-trigger {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
cursor: pointer;
}
.upload-trigger .placeholder {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.upload-trigger svg {
width: 40px;
height: 40px;
color: #94a3b8;
margin-bottom: 12px;
}
.upload-trigger span {
font-size: 14px;
color: #64748b;
}
.preview-img {
max-width: 100%;
max-height: 100%;
object-fit: contain;
}
.stage-footer {
padding: 24px;
display: flex;
justify-content: flex-end;
gap: 12px;
}
.btn-primary {
height: 44px;
padding: 0 24px;
background: #3b82f6;
color: white;
border: none;
border-radius: 12px;
font-weight: 700;
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 8px;
transition: all 0.2s ease;
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.2);
}
.btn-primary:hover:not(:disabled) {
background: #2563eb;
transform: translateY(-1px);
box-shadow: 0 6px 16px rgba(37, 99, 235, 0.3);
}
.btn-primary:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.btn-ghost {
height: 44px;
padding: 0 24px;
background: #f1f5f9;
color: #475569;
border: none;
border-radius: 12px;
font-weight: 700;
cursor: pointer;
transition: all 0.2s ease;
}
.btn-ghost:hover {
background: #e2e8f0;
color: #1e293b;
}
.warning-banner {
margin-top: 12px;
background: #fffbeb;
border: 1px solid #fef3c7;
border-radius: 12px;
padding: 12px 16px;
display: flex;
gap: 12px;
align-items: flex-start;
}
.w-icon {
width: 20px;
height: 20px;
color: #d97706;
flex-shrink: 0;
}
.w-list {
font-size: 13px;
color: #92400e;
line-height: 1.5;
}
.table-container {
padding: 0 24px;
}
.flat-table :deep(.el-table__header-wrapper) th {
background: #f8fafc;
color: #475569;
font-weight: 700;
border-bottom: 1px solid #f1f5f9;
}
.flat-table :deep(.el-table__row) td {
border-bottom: 1px solid #f1f5f9;
padding: 8px 0;
}
.week-range, .section-range {
display: flex;
align-items: center;
gap: 4px;
}
.week-range span, .section-range span {
color: #94a3b8;
}
.success-dot {
display: inline-block;
width: 8px;
height: 8px;
background: #22c55e;
border-radius: 50%;
}
.row-warning-dot {
cursor: help;
}
.spinner {
width: 16px;
height: 16px;
border: 2px solid rgba(255, 255, 255, 0.3);
border-top-color: #ffffff;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
.btn-icon-danger {
background: transparent;
border: none;
color: #ef4444;
cursor: pointer;
padding: 4px;
border-radius: 6px;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s ease;
}
.btn-icon-danger:hover {
background: #fee2e2;
}
.btn-icon-danger svg {
width: 18px;
height: 18px;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
:deep(.el-input__wrapper), :deep(.el-input-number), :deep(.el-select) {
border-radius: 8px !important;
box-shadow: none !important;
border: 1px solid #e2e8f0 !important;
}
:deep(.el-input__wrapper.is-focus) {
border-color: #3b82f6 !important;
}
.drag-handle {
cursor: grab;
color: #94a3b8;
display: flex;
align-items: center;
justify-content: center;
padding: 4px;
border-radius: 4px;
transition: all 0.2s ease;
}
.drag-handle:hover {
background: #f1f5f9;
color: #64748b;
}
.drag-handle svg {
width: 16px;
height: 16px;
}
.drag-handle:active {
cursor: grabbing;
}
.header-actions {
margin-left: auto;
display: flex;
align-items: center;
gap: 12px;
}
.btn-sm {
height: 32px;
padding: 0 12px;
font-size: 13px;
border-radius: 8px;
}
.add-row-form {
display: flex;
flex-direction: column;
gap: 16px;
}
.form-item {
display: flex;
flex-direction: column;
gap: 8px;
}
.form-item label {
font-size: 13px;
font-weight: 600;
color: #64748b;
}
.form-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
}
.range-inputs {
display: flex;
align-items: center;
gap: 12px;
}
.range-inputs span {
color: #94a3b8;
font-size: 13px;
}
.flex-row {
flex-direction: row;
justify-content: space-between;
align-items: center;
}
.dialog-footer {
display: flex;
justify-content: flex-end;
gap: 12px;
}
</style>

View File

@@ -64,6 +64,54 @@ const eventLookup = computed(() => {
return map
})
// isFullyCovered 负责判断一个 Slot (2节课) 是否被之前的跨行事件完全遮挡。
// 如果只是部分遮挡(如 span=3 占用了下一 Slot 的第一节),则不视为完全遮挡,允许渲染以维持布局完整性。
function isFullyCovered(dayOfWeek: number, order: number) {
const endSectionOfOrder = order * 2
for (let prevOrder = 1; prevOrder < order; prevOrder++) {
const event = resolveEvent(dayOfWeek, prevOrder)
if (event && event.type !== 'empty') {
const eventEndSection = (prevOrder - 1) * 2 + (event.span || 2)
if (eventEndSection >= endSectionOfOrder) {
return true
}
}
}
return false
}
// resolveGridRow 负责计算格子的精确 Grid 布局位置。
// 特别处理了“部分遮挡”的情况:如果一个空格子被上方的长课时占用了第一节,则该格子会自动下移并缩短,从而紧贴在课程下方。
function resolveGridRow(dayOfWeek: number, order: number) {
const event = resolveEvent(dayOfWeek, order)
const startRow = (order - 1) * 2 + 2 // 该 Slot 默认的起始行Header 占 1 行,每节课 1 行)
// 1. 如果是真实课程/任务,遵循其原始 order 对应的起点,并按其实际 span 拉伸。
if (event && event.type !== 'empty') {
return `${startRow} / span ${event.span || 2}`
}
// 2. 如果是空格子,检查上方是否有跨行事件侵入了当前 Slot。
let currentStartRow = startRow
const currentEndRow = startRow + 2 // 一个 Slot 默认占 2 行
for (let prevOrder = 1; prevOrder < order; prevOrder++) {
const prevEvent = resolveEvent(dayOfWeek, prevOrder)
if (prevEvent && prevEvent.type !== 'empty') {
// 计算上方事件在网格中的结束行
const prevEndRow = (prevOrder - 1) * 2 + 2 + (prevEvent.span || 2)
// 如果上方事件的结束行超过了当前格子的起始行,则需要将起点下移
if (prevEndRow > currentStartRow) {
currentStartRow = prevEndRow
}
}
}
const finalSpan = currentEndRow - currentStartRow
return `${currentStartRow} / span ${Math.max(0, finalSpan)}`
}
function resolveEvent(dayOfWeek: number, order: number) {
return eventLookup.value.get(`${dayOfWeek}-${order}`)
}
@@ -346,21 +394,30 @@ function handlePreviewDragEnd() {
</header>
<div class="planning-board__grid" @dragover="handleExternalDragOver">
<div class="planning-board__corner" />
<div class="planning-board__corner" style="grid-row: 1; grid-column: 1;" />
<div v-for="header in weekHeaders" :key="header.dayOfWeek" class="planning-board__day-head">
<div
v-for="header in weekHeaders"
:key="header.dayOfWeek"
class="planning-board__day-head"
:style="{ gridRow: 1, gridColumn: header.dayOfWeek + 1 }"
>
<span>{{ header.label }}</span>
<small>{{ header.dateLabel }}</small>
</div>
<template v-for="slot in sectionSlots" :key="slot.order">
<div class="planning-board__time-cell">
<div
class="planning-board__time-cell"
:style="{ gridRow: `${(slot.order - 1) * 2 + 2} / span 2`, gridColumn: 1 }"
>
<strong>{{ slot.title }}</strong>
<small>{{ slot.timeRange }}</small>
</div>
<article
v-for="header in weekHeaders"
v-show="!isFullyCovered(header.dayOfWeek, slot.order)"
:key="`${weekData?.week ?? 0}-${header.dayOfWeek}-${slot.order}`"
class="planning-board__cell"
:class="[
@@ -375,7 +432,12 @@ function handlePreviewDragEnd() {
'planning-board__cell--dragover': dragOverCellKey === buildCellKey(header.dayOfWeek, slot.order),
},
]"
:style="{ '--anim-delay': (header.dayOfWeek - 1) * 0.035 + (slot.order - 1) * 0.045 + 's' }"
:style="{
'--anim-delay': (header.dayOfWeek - 1) * 0.035 + (slot.order - 1) * 0.045 + 's',
gridRow: resolveGridRow(header.dayOfWeek, slot.order),
gridColumn: header.dayOfWeek + 1,
zIndex: (resolveEvent(header.dayOfWeek, slot.order) && resolveEvent(header.dayOfWeek, slot.order)!.type !== 'empty') ? 2 : 1
}"
:draggable="isWholeCellDraggable(resolveEvent(header.dayOfWeek, slot.order))"
@dragstart="handlePreviewDragStart(header.dayOfWeek, slot.order, $event)"
@dragover="handlePreviewDragOver(header.dayOfWeek, slot.order, $event)"
@@ -467,6 +529,7 @@ function handlePreviewDragEnd() {
--planning-time-column-width: 68px;
--planning-day-column-min: 96px;
--planning-cell-height: clamp(72px, 9.2vh, 112px);
--planning-section-height: calc(var(--planning-cell-height) / 2);
min-width: 0;
min-height: 0;
border-radius: 20px;
@@ -490,6 +553,7 @@ function handlePreviewDragEnd() {
min-height: 0;
display: grid;
grid-template-columns: var(--planning-time-column-width) repeat(7, minmax(var(--planning-day-column-min), 1fr));
grid-template-rows: auto repeat(12, var(--planning-section-height));
gap: var(--planning-grid-gap-y) var(--planning-grid-gap-x);
padding: var(--planning-grid-padding-y) var(--planning-grid-padding-x) 24px;
overflow: auto;
@@ -518,7 +582,7 @@ function handlePreviewDragEnd() {
}
.planning-board__time-cell {
min-height: var(--planning-cell-height);
min-height: 0;
display: grid;
align-content: center;
justify-items: end;
@@ -541,7 +605,7 @@ function handlePreviewDragEnd() {
.planning-board__cell {
position: relative;
min-height: var(--planning-cell-height);
min-height: 0;
border-radius: 14px;
border: 1px solid transparent;
padding: 14px;
@@ -857,7 +921,7 @@ function handlePreviewDragEnd() {
.planning-board__time-cell,
.planning-board__cell {
min-height: 98px;
min-height: 0;
}
.planning-board__cell {

View File

@@ -66,3 +66,25 @@ a {
box-shadow: var(--shadow-soft);
backdrop-filter: blur(10px);
}
/* Element Plus Overrides for Flat Modern Style */
.el-dialog, .el-message-box {
border-radius: 24px !important;
border: 1px solid rgba(15, 23, 42, 0.05) !important;
box-shadow: 0 20px 60px rgba(15, 23, 42, 0.1) !important;
}
.el-message-box__headerbtn, .el-dialog__headerbtn {
top: 16px !important;
right: 16px !important;
}
.el-button {
border-radius: 12px !important;
font-weight: 600 !important;
}
.el-button--primary {
background: #3b82f6 !important;
border-color: #3b82f6 !important;
}

View File

@@ -97,3 +97,42 @@ export interface ScheduleDeletePayloadItem {
delete_course: boolean
delete_embedded_task: boolean
}
export interface CourseDraftRow {
row_id: string
course_name: string
location: string
is_allow_tasks: boolean
start_week: number | null
end_week: number | null
day_of_week: number | null
start_section: number | null
end_section: number | null
week_type: 'all' | 'odd' | 'even' | ''
confidence: number
raw_text: string
row_warnings: string[]
}
export interface CourseImportPayload {
courses: Array<{
course_name: string
location: string
is_allow_tasks: boolean
arrangements: Array<{
start_week: number
end_week: number
day_of_week: number
start_section: number
end_section: number
week_type: 'all' | 'odd' | 'even'
}>
}>
}
export interface CourseImageParseResponse {
draft_status: 'success' | 'partial' | 'reject'
message: string
warnings: string[]
rows: CourseDraftRow[]
}

View File

@@ -17,6 +17,7 @@ import {
import CreateTaskClassDialog from '@/components/schedule/CreateTaskClassDialog.vue'
import TaskClassSidebar from '@/components/schedule/TaskClassSidebar.vue'
import WeekPlanningBoard from '@/components/schedule/WeekPlanningBoard.vue'
import CourseImageImportDialog from '@/components/schedule/CourseImageImportDialog.vue'
import type { ApplyBatchIntoScheduleItem, ScheduleWeekData, ScheduleWeekEvent, TaskClassDetail, TaskClassListItem } from '@/types/schedule'
import { formatHeaderDate } from '@/utils/date'
@@ -130,6 +131,7 @@ const applyingLoading = ref(false)
const deletingLoading = ref(false)
const createDialogVisible = ref(false)
const createDialogLoading = ref(false)
const courseImportDialogVisible = ref(false)
const taskClasses = ref<TaskClassListItem[]>([])
const expandedTaskClassId = ref<number | null>(null)
@@ -1216,6 +1218,15 @@ onMounted(async () => {
取消多选
</button>
<button
v-if="!manualEditMode && !scheduleSelectionMode"
type="button"
class="schedule-board__toolbar-button schedule-board__toolbar-button--ghost"
@click="courseImportDialogVisible = true"
>
导入课表
</button>
<button
v-if="showSmartPlanningButton"
type="button"
@@ -1299,6 +1310,11 @@ onMounted(async () => {
:loading="createDialogLoading"
@submit="handleCreateTaskClass"
/>
<CourseImageImportDialog
v-model="courseImportDialogVisible"
@success="loadWeekData(currentWeek ?? undefined, { force: true })"
/>
</template>
<style scoped>