2026-08-28-oa-start-history.md 25 KB

OA 发起审批 · 流程历史记录 Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: 在「发起审批」流程卡片增加「历史记录」弹窗,按权限查看本人或租户内该流程全部实例,支持筛选、分页、查看与打印。

Architecture: 新接口 GET /instances/history(须注册在 /instances/{id} 之前)+ oa_engine.list_instance_history;全员范围由菜单权限码 oa:start:history:all 后端强制判定;前端 OaAppHistoryDialog 挂在发起页卡片上。

Tech Stack: FastAPI · SQLAlchemy · unittest;Vue 3 · Element Plus · useMenuPerm

Spec: docs/superpowers/specs/2026-08-28-oa-start-history-design.md
UI Demo: docs/demos/oa-start-history-demo.html


File map

Path Responsibility
Create: backend/tests/test_oa_instance_history.py history 列表 TDD
Modify: backend/app/services/oa_engine.py list_instance_history;_enrich_list_rows 增加 preview_limit 与 finishTime
Modify: backend/app/api/oa_approval.py 注册 history 路由(在 {instance_id} 之前)
Create: docs/sql/2026-08-28-oa-start-history-all-perm.sql 按钮菜单种子 oa:start:history:all
Modify: web-admin/src/api/oaApproval.ts fetchOaInstanceHistory
Create: web-admin/src/components/oa-approval/OaAppHistoryDialog.vue 历史弹窗
Modify: web-admin/src/views/oa-approval/start/index.vue 卡片按钮 + 打开弹窗
Run: ./scripts/update-graph.sh all 图谱增量(backend + frontend)

常量:

  • 权限码:oa:start:history:all
  • 终态(有 finishTime):completed / rejected / cancelled
  • previewFields 上限:history=6;既有 tasks 列表保持 3

详情可见性说明: 当前 get_instance_detail 仅按 tenant_no 校验,管理端任意用户已可打开同租户实例。本期无需再改详情鉴权;实现后用手工/单测确认即可。


Task 1: 后端 history 失败测试(TDD)

Files:

  • Create: backend/tests/test_oa_instance_history.py

  • [ ] Step 1: 写入测试文件

"""OA list_instance_history:按 appId + 全员权限收窄。"""
from __future__ import annotations

import unittest
from datetime import datetime

from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker

from app.models.base import Base
from app.models.models import OaApp, OaAppVersion, OaInstance, SysUser
from app.services import oa_engine


class TestOaInstanceHistory(unittest.TestCase):
    def setUp(self):
        self.engine = create_engine("sqlite:///:memory:")
        Base.metadata.create_all(
            self.engine,
            tables=[
                OaApp.__table__,
                OaAppVersion.__table__,
                SysUser.__table__,
                OaInstance.__table__,
            ],
        )
        Session = sessionmaker(bind=self.engine, autoflush=False, autocommit=False)
        self.db = Session()
        t1 = datetime(2026, 8, 10, 9, 0, 0)
        t2 = datetime(2026, 8, 20, 15, 30, 0)
        t3 = datetime(2026, 8, 25, 11, 0, 0)
        self.db.add_all(
            [
                OaApp(
                    id="app-leave",
                    app_name="请假申请",
                    initiator_scope="all",
                    admin_scope="all_oa_admin",
                    status="1",
                    tenant_no="zkxw",
                    create_dept_id="",
                    create_time=t1,
                    update_time=t1,
                    del_flag=0,
                ),
                OaAppVersion(
                    id="v-leave",
                    app_id="app-leave",
                    version_no=1,
                    status="published",
                    is_latest_published="1",
                    form_schema_json='{"fields":[{"type":"input","field":"leaveType","title":"请假类型"},{"type":"input","field":"days","title":"天数"}]}',
                    flow_tree_json="{}",
                    advanced_settings_json="{}",
                    tenant_no="zkxw",
                    create_dept_id="",
                    create_time=t1,
                    update_time=t1,
                    del_flag=0,
                ),
                SysUser(
                    id="u-a",
                    user_name="alice",
                    nick_name="张三",
                    tenant_no="zkxw",
                    create_dept_id="",
                    create_time=t1,
                    update_time=t1,
                    del_flag=0,
                ),
                SysUser(
                    id="u-b",
                    user_name="bob",
                    nick_name="李四",
                    tenant_no="zkxw",
                    create_dept_id="",
                    create_time=t1,
                    update_time=t1,
                    del_flag=0,
                ),
                OaInstance(
                    id="inst-alice-done",
                    app_id="app-leave",
                    app_version_id="v-leave",
                    title="请假-张三",
                    initiator_user_id="u-a",
                    form_data_json='{"leaveType":"年假","days":"2"}',
                    status="completed",
                    tenant_no="zkxw",
                    create_dept_id="",
                    create_time=t1,
                    update_time=t2,
                    del_flag=0,
                ),
                OaInstance(
                    id="inst-bob-run",
                    app_id="app-leave",
                    app_version_id="v-leave",
                    title="请假-李四",
                    initiator_user_id="u-b",
                    form_data_json='{"leaveType":"事假","days":"1"}',
                    status="running",
                    tenant_no="zkxw",
                    create_dept_id="",
                    create_time=t3,
                    update_time=t3,
                    del_flag=0,
                ),
            ]
        )
        self.db.commit()

    def tearDown(self):
        self.db.close()
        self.engine.dispose()

    def test_requires_app_id(self):
        with self.assertRaises(oa_engine.OaEngineError):
            oa_engine.list_instance_history(
                self.db,
                tenant_no="zkxw",
                user_id="u-a",
                can_view_all=False,
                app_id="",
            )

    def test_self_scope_hides_others(self):
        data = oa_engine.list_instance_history(
            self.db,
            tenant_no="zkxw",
            user_id="u-a",
            can_view_all=False,
            app_id="app-leave",
        )
        self.assertEqual(data["total"], 1)
        self.assertFalse(data["canViewAll"])
        self.assertEqual(data["list"][0]["id"], "inst-alice-done")

    def test_all_scope_returns_both(self):
        data = oa_engine.list_instance_history(
            self.db,
            tenant_no="zkxw",
            user_id="u-a",
            can_view_all=True,
            app_id="app-leave",
        )
        self.assertEqual(data["total"], 2)
        self.assertTrue(data["canViewAll"])
        ids = {r["id"] for r in data["list"]}
        self.assertEqual(ids, {"inst-alice-done", "inst-bob-run"})

    def test_initiator_name_ignored_without_all(self):
        data = oa_engine.list_instance_history(
            self.db,
            tenant_no="zkxw",
            user_id="u-a",
            can_view_all=False,
            app_id="app-leave",
            initiator_name="李四",
        )
        self.assertEqual(data["total"], 1)
        self.assertEqual(data["list"][0]["id"], "inst-alice-done")

    def test_finish_time_only_on_terminal(self):
        data = oa_engine.list_instance_history(
            self.db,
            tenant_no="zkxw",
            user_id="u-a",
            can_view_all=True,
            app_id="app-leave",
        )
        by_id = {r["id"]: r for r in data["list"]}
        self.assertTrue(by_id["inst-alice-done"].get("finishTime"))
        self.assertFalse(by_id["inst-bob-run"].get("finishTime"))

    def test_finish_time_filter(self):
        data = oa_engine.list_instance_history(
            self.db,
            tenant_no="zkxw",
            user_id="u-a",
            can_view_all=True,
            app_id="app-leave",
            finish_time_from="2026-08-20",
            finish_time_to="2026-08-20",
        )
        self.assertEqual(data["total"], 1)
        self.assertEqual(data["list"][0]["id"], "inst-alice-done")

    def test_preview_fields_limit_six(self):
        data = oa_engine.list_instance_history(
            self.db,
            tenant_no="zkxw",
            user_id="u-a",
            can_view_all=False,
            app_id="app-leave",
        )
        pf = data["list"][0].get("previewFields") or []
        self.assertLessEqual(len(pf), 6)
        self.assertGreaterEqual(len(pf), 1)


if __name__ == "__main__":
    unittest.main()

注:SysUser 必填字段若与仓库模型不一致,按 tests/test_oa_tasks_filters.py 现有构造补齐。form_schema_json 形状若与 flatten_form_fields_for_summary 不符,可改为该函数单测中已用过的 schema 样例,或对本用例仅断言 previewFields 为 list 且 len <= 6。

  • Step 2: 跑测确认失败
cd backend && source venv/bin/activate && pytest tests/test_oa_instance_history.py -v

Expected: FAIL(list_instance_history 未定义)

  • Step 3: Commit 测试
git add backend/tests/test_oa_instance_history.py
git commit -m "$(cat <<'EOF'
test: add OA instance history list failing tests

EOF
)"

Task 2: 实现 list_instance_history 与 enrich 扩展

Files:

  • Modify: backend/app/services/oa_engine.py

  • [ ] Step 1: 扩展 _enrich_list_rows

在函数签名增加 preview_limit: int = 3,将 [:3] 改为 [: max(0, int(preview_limit or 3))]。

在 merged 行上增加 finishTime:

_FINISH_STATUSES = frozenset({"completed", "rejected", "cancelled"})

# 在 merged.update(summary) 之后:
st = (inst.status or "").strip()
if st in _FINISH_STATUSES and inst.update_time:
    merged["finishTime"] = inst.update_time.strftime("%Y-%m-%d %H:%M:%S")
else:
    merged["finishTime"] = None

既有 list_tasks 调用不传 preview_limit,保持默认 3。

  • Step 2: 新增 list_instance_history

放在 list_tasks 附近:

def list_instance_history(
    db: Session,
    *,
    tenant_no: str,
    user_id: str,
    can_view_all: bool,
    app_id: str,
    instance_id: Optional[str] = None,
    initiator_name: Optional[str] = None,
    apply_time_from: Optional[str] = None,
    apply_time_to: Optional[str] = None,
    finish_time_from: Optional[str] = None,
    finish_time_to: Optional[str] = None,
    instance_status: Optional[str] = None,
    page: int = 1,
    page_size: int = 20,
) -> Dict[str, Any]:
    app_id = (app_id or "").strip()
    if not app_id:
        raise OaEngineError("appId 不能为空")
    page = max(1, int(page or 1))
    page_size = min(100, max(1, int(page_size or 20)))
    offset = (page - 1) * page_size

    q = db.query(OaInstance).filter(
        OaInstance.tenant_no == tenant_no,
        OaInstance.del_flag == 0,
        OaInstance.app_id == app_id,
    )
    if not can_view_all:
        q = q.filter(OaInstance.initiator_user_id == user_id)
        initiator_name = None

    iid = (instance_id or "").strip()
    if iid:
        q = q.filter(OaInstance.id.like(f"%{iid}%"))

    q, _, _ = _apply_instance_list_filters(
        q,
        initiator_name=initiator_name,
        apply_time_from=apply_time_from,
        apply_time_to=apply_time_to,
        instance_status=instance_status,
    )

    ft0 = _parse_day_start(finish_time_from)
    ft1 = _parse_day_end(finish_time_to)
    if ft0 is not None or ft1 is not None:
        q = q.filter(OaInstance.status.in_(tuple(_FINISH_STATUSES)))
        if ft0 is not None:
            q = q.filter(OaInstance.update_time >= ft0)
        if ft1 is not None:
            q = q.filter(OaInstance.update_time <= ft1)

    total = q.count()
    insts = (
        q.order_by(OaInstance.create_time.desc())
        .offset(offset)
        .limit(page_size)
        .all()
    )
    rows = [_instance_to_dict(i) for i in insts]
    return {
        "list": _enrich_list_rows(
            db,
            tenant_no=tenant_no,
            rows=rows,
            instances=insts,
            preview_limit=6,
        ),
        "total": int(total),
        "canViewAll": bool(can_view_all),
    }

将 _FINISH_STATUSES 提为模块级常量,供 enrich 与 history 共用。

  • Step 3: 跑测通过
cd backend && source venv/bin/activate && pytest tests/test_oa_instance_history.py -v

Expected: PASS(若 schema flatten 导致 preview 断言不稳,放宽该断言后仍须其余用例 PASS)

  • Step 4: 回归任务筛选
cd backend && pytest tests/test_oa_tasks_filters.py tests/test_oa_tasks_pagination.py -v

Expected: PASS

  • Step 5: Commit
git add backend/app/services/oa_engine.py backend/tests/test_oa_instance_history.py
git commit -m "$(cat <<'EOF'
feat(oa): add list_instance_history with scoped preview fields

EOF
)"

Task 3: 注册 API 路由

Files:

  • Modify: backend/app/api/oa_approval.py

  • [ ] Step 1: 在 GET /instances/{instance_id} 之前插入路由

OA_START_HISTORY_ALL_PERM = "oa:start:history:all"

@router.get("/instances/history")
async def list_oa_instance_history(
    appId: str = Query(..., description="流程应用 ID"),
    instanceId: str = Query(""),
    instanceStatus: str = Query(""),
    initiatorName: str = Query(""),
    applyTimeFrom: str = Query(""),
    applyTimeTo: str = Query(""),
    finishTimeFrom: str = Query(""),
    finishTimeTo: str = Query(""),
    page: int = Query(1),
    pageSize: int = Query(20, alias="pageSize"),
    user: dict = Depends(require_admin_or_super_admin),
    db: Session = Depends(get_db),
):
    from app.core.menu_guard import user_has_menu_perm
    from app.services.oa_engine import OaEngineError, list_instance_history

    try:
        tenant_no = _get_tenant_no(db, user)
        can_view_all = user_has_menu_perm(db, user, OA_START_HISTORY_ALL_PERM)
        data = list_instance_history(
            db,
            tenant_no=tenant_no,
            user_id=user.get("user_id") or "",
            can_view_all=can_view_all,
            app_id=appId,
            instance_id=instanceId or None,
            initiator_name=initiatorName or None,
            apply_time_from=applyTimeFrom or None,
            apply_time_to=applyTimeTo or None,
            finish_time_from=finishTimeFrom or None,
            finish_time_to=finishTimeTo or None,
            instance_status=instanceStatus or None,
            page=page,
            page_size=pageSize,
        )
        return success_response(data)
    except OaEngineError as e:
        return error_response(400, str(e))

务必放在 @router.get("/instances/{instance_id}") 上方,避免 history 被当成 instance_id。

  • Step 2: 手工冒烟(可选)

启动本地后端后:

# 需带登录 token;无 token 时期望 401/403,至少确认路由不是 404 落到 instance 详情
curl -s -o /dev/null -w "%{http_code}" "http://127.0.0.1:8000/web/admin/oa-approval/instances/history?appId=x"

Expected: 非 404(多为 401/403/400)

  • Step 3: Commit
git add backend/app/api/oa_approval.py
git commit -m "$(cat <<'EOF'
feat(oa): expose GET /instances/history for start-page history

EOF
)"

Task 4: 菜单权限种子 SQL

Files:

  • Create: docs/sql/2026-08-28-oa-start-history-all-perm.sql

  • [ ] Step 1: 编写 SQL

先查库中「发起审批」菜单 id(执行环境以实际为准):

-- 查找发起审批菜单
SELECT id, parent_id, menu_name, perms, menu_type
FROM sys_menu
WHERE del_flag = 0 AND (perms = 'oa:start' OR menu_name LIKE '%发起审批%');

脚本模板(将 @parent_id 换成上一步查到的 id;id 用新 UUID):

-- OA 发起审批 · 全员历史权限(按钮 F)
-- perms: oa:start:history:all
-- 执行前请确认 parent_id 为「发起审批」菜单 id

INSERT INTO sys_menu (
  id, parent_id, menu_name, menu_type, path, component, perms,
  icon, order_num, visible, status, is_frame, is_cache,
  tenant_no, remark, create_by, create_dept_id, create_time, update_by, update_time, del_flag
) VALUES (
  REPLACE(UUID(), '-', ''),
  /* parent_id */ 'REPLACE_WITH_OA_START_MENU_ID',
  '全员历史记录',
  'F',
  '',
  '',
  'oa:start:history:all',
  '',
  90,
  '0',
  '0',
  0,
  0,
  'zkxw',
  '发起审批历史弹窗:有此权限可查看本租户该流程全部实例',
  'system',
  '',
  NOW(),
  'system',
  NOW(),
  0
);

若项目 sys_menu 列名/必填与模板不一致,对照 docs/sql/zkxw.sql 中已有 menu_type='F' 行改写;保持 perms='oa:start:history:all' 不变。

在脚本头部用注释写明:执行后需在「角色管理」勾选该按钮权限。

  • Step 2: Commit
git add docs/sql/2026-08-28-oa-start-history-all-perm.sql
git commit -m "$(cat <<'EOF'
docs(sql): seed oa:start:history:all button permission

EOF
)"

Task 5: 前端 API

Files:

  • Modify: web-admin/src/api/oaApproval.ts

  • [ ] Step 1: 增加方法(放在 fetchOaTasks 附近)

export function fetchOaInstanceHistory(params: {
  appId: string
  instanceId?: string
  instanceStatus?: string
  initiatorName?: string
  applyTimeFrom?: string
  applyTimeTo?: string
  finishTimeFrom?: string
  finishTimeTo?: string
  page?: number
  pageSize?: number
}) {
  return request.get<{
    list: any[]
    total: number
    canViewAll?: boolean
  }>({
    url: `${BASE}/instances/history`,
    params
  })
}
  • Step 2: Commit
git add web-admin/src/api/oaApproval.ts
git commit -m "$(cat <<'EOF'
feat(web-admin): add fetchOaInstanceHistory API

EOF
)"

Task 6: OaAppHistoryDialog 组件

Files:

  • Create: web-admin/src/components/oa-approval/OaAppHistoryDialog.vue

  • [ ] Step 1: 创建弹窗组件

对齐 mine/index.vue 的筛选/表格/打印;关键结构:

<!-- 要点(完整实现时展开为完整 SFC) -->
<template>
  <el-dialog
    v-model="visible"
    class="dialog-form-layout"
    :title="`历史记录 · ${appName}`"
    width="1080px"
    append-to-body
    destroy-on-close
    align-center
    modal-class="protocol-dialog-mask"
    @closed="onClosed"
  >
    <div class="filter-block">...</div>
    <!-- 流程名称只读;申请人 v-if="canViewAll" -->
    <el-table class="list-table" :data="rows" v-loading="loading" stripe>
      <!-- 列序见 spec §5.4;动态列:previewFieldLabels -->
      <!-- 操作:查看 / 打印 -->
    </el-table>
    <el-pagination ... />
  </el-dialog>
</template>

<script setup lang="ts">
  import { computed, reactive, ref } from 'vue'
  import { useRouter } from 'vue-router'
  import { Printer, RefreshLeft, Search, View } from '@element-plus/icons-vue'
  import { fetchOaInstanceDetail, fetchOaInstanceHistory } from '@/api/oaApproval'
  import { useMenuPerm } from '@/hooks/core/useMenuPerm'
  import { useUserStore } from '@/store/modules/user'
  import { exportOaApprovalPdf } from '@/utils/oa-approval-print'

  const OA_START_HISTORY_ALL = 'oa:start:history:all'
  const { hasMenuPerm } = useMenuPerm()
  const canViewAll = computed(() => hasMenuPerm(OA_START_HISTORY_ALL))

  const visible = ref(false)
  const appId = ref('')
  const appName = ref('')
  // searchForm / applyTimeRange / finishTimeRange / page / pageSize / rows / total / loading / printingId

  function open(app: { id: string; appName?: string }) {
    appId.value = app.id
    appName.value = app.appName || ''
    resetFilters()
    visible.value = true
    load()
  }

  async function load() {
    if (!appId.value) return
    loading.value = true
    try {
      const params: any = {
        appId: appId.value,
        instanceId: searchForm.instanceId || undefined,
        instanceStatus: searchForm.instanceStatus || undefined,
        applyTimeFrom: applyTimeRange.value?.[0],
        applyTimeTo: applyTimeRange.value?.[1],
        finishTimeFrom: finishTimeRange.value?.[0],
        finishTimeTo: finishTimeRange.value?.[1],
        page: page.value,
        pageSize: pageSize.value
      }
      if (canViewAll.value && searchForm.initiatorName) {
        params.initiatorName = searchForm.initiatorName
      }
      const data = await fetchOaInstanceHistory(params)
      rows.value = data?.list || []
      total.value = Number(data?.total || 0)
    } finally {
      loading.value = false
    }
  }

  const previewFieldLabels = computed(() => {
    const labels: string[] = []
    for (const row of rows.value) {
      for (const f of row.previewFields || []) {
        const lb = (f.label || '').trim()
        if (lb && !labels.includes(lb)) labels.push(lb)
      }
    }
    return labels.slice(0, 6)
  })

  function previewValue(row: any, label: string) {
    const hit = (row.previewFields || []).find((f: any) => f.label === label)
    return hit?.value || '-'
  }

  function openDetail(row: any) {
    const id = row.id
    if (!id) return
    visible.value = false
    router.push({ path: `/system-manage/oa-approval/instances/${id}` })
  }

  async function printRow(row: any) {
    // 同 mine/index.vue:fetchOaInstanceDetail + exportOaApprovalPdf
  }

  defineExpose({ open })
</script>

动态列表头用 v-for="label in previewFieldLabels";状态 Tag 复用 mine 的 statusType / statusText。

空数据:el-empty 或表格空文案「暂无历史记录」。

  • Step 2: Commit
git add web-admin/src/components/oa-approval/OaAppHistoryDialog.vue
git commit -m "$(cat <<'EOF'
feat(web-admin): add OaAppHistoryDialog for per-app instance history

EOF
)"

Task 7: 发起页挂载按钮

Files:

  • Modify: web-admin/src/views/oa-approval/start/index.vue

  • [ ] Step 1: 改卡片操作区

<div class="oa-app-card__ops">
  <el-button class="oa-app-card__btn" @click.stop="openHistory(row)">
    <el-icon><Clock /></el-icon>
    历史记录
  </el-button>
  <el-button class="oa-app-card__btn primary" @click.stop="onPick(row)">
    <el-icon><Promotion /></el-icon>
    发起
  </el-button>
</div>

模板底部增加:

<OaAppHistoryDialog ref="historyDialogRef" />

script:

import { Clock, Promotion, Search } from '@element-plus/icons-vue'
import OaAppHistoryDialog from '@/components/oa-approval/OaAppHistoryDialog.vue'

const historyDialogRef = ref<InstanceType<typeof OaAppHistoryDialog> | null>(null)

function openHistory(row: any) {
  historyDialogRef.value?.open({ id: row.id, appName: row.appName })
}

按钮始终显示,不做 v-if 权限隐藏。

  • Step 2: 本地 UI 冒烟
cd web-admin && pnpm dev

打开 /#/system-manage/oa-approval/start:确认卡片有「历史记录」;点开弹窗;有/无权限角色验证申请人筛选项与数据范围(需先执行 Task 4 SQL 并给角色勾选)。

  • Step 3: Commit
git add web-admin/src/views/oa-approval/start/index.vue
git commit -m "$(cat <<'EOF'
feat(web-admin): show history button on OA start approval cards

EOF
)"

Task 8: 图谱与收尾核对

Files:

  • Run: ./scripts/update-graph.sh all

  • [ ] Step 1: 更新知识图谱

./scripts/update-graph.sh all
  • Step 2: Spec 验收对照

对照 docs/superpowers/specs/2026-08-28-oa-start-history-design.md §8:

  1. 历史按钮在发起左侧且始终可见
  2. 无权限仅本人、无申请人筛选
  3. 有权限全员 + 申请人筛选
  4. 流程名称只读;含审批 ID / 状态 / 时间段
  5. 列序:业务字段在发起时间前,≤6
  6. 倒序 + 分页
  7. 查看、打印可用
  8. 后端强制收窄
  9. 草稿不出现
  • Step 3: Commit 图谱(若有 diff)
git add backend/graphify-out web-admin/graphify-out
git commit -m "$(cat <<'EOF'
chore: update graphify after OA start history feature

EOF
)"

Spec coverage checklist

Spec 要求 Task
卡片「历史记录」按钮 Task 7
弹窗 + 筛选 + 分页 Task 6
锁定 appId / 流程名称只读 Task 6
审批 ID / 完成时间不改表 Task 2
全员权限字符 Task 3 + 4 + 6
无权限仅本人 Task 1–2
previewFields×6 Task 2 + 6
查看 + 打印 Task 6
草稿排除 Task 2(只查 oa_instance)
SQL 种子 Task 4
详情放宽 已有租户级开放;Task 8 核对

Placeholder / consistency self-check

  • 权限码全文统一:oa:start:history:all
  • 路由 path:/instances/history(在 {instance_id} 前)
  • finishTime 字段名前后端一致
  • preview_limit 仅 history=6,tasks 默认 3