1
0
mirror of https://github.com/fumiama/paper-manager.git synced 2026-06-30 07:40:28 +08:00

finish 试卷生成

This commit is contained in:
源文雨
2023-04-25 00:36:46 +08:00
parent aabd25ea19
commit 940b2618b3
10 changed files with 280 additions and 78 deletions

View File

@@ -4,10 +4,16 @@ import (
"encoding/json" "encoding/json"
"io" "io"
"net/http" "net/http"
"os"
"time"
"github.com/FloatTech/ttl"
"github.com/fumiama/go-docx"
"github.com/fumiama/paper-manager/backend/global" "github.com/fumiama/paper-manager/backend/global"
) )
var genfilecache = ttl.NewCache[int, *docx.Docx](time.Minute * 10)
func init() { func init() {
apimap["/api/genFile"] = &apihandler{"POST", func(w http.ResponseWriter, r *http.Request) { apimap["/api/genFile"] = &apihandler{"POST", func(w http.ResponseWriter, r *http.Request) {
user := usertokens.Get(r.Header.Get("Authorization")) user := usertokens.Get(r.Header.Get("Authorization"))
@@ -27,6 +33,21 @@ func init() {
writeresult(w, codeError, nil, err.Error(), typeError) writeresult(w, codeError, nil, err.Error(), typeError)
return return
} }
genfilecache.Set(*user.ID, docf)
writeresult(w, codeSuccess, "请在10分钟内下载, 且不要在下载完成前关闭页面, 云端不会保存", messageOk, typeSuccess)
}}
apimap["/api/dlGen"] = &apihandler{"GET", func(w http.ResponseWriter, r *http.Request) {
user := usertokens.Get(r.Header.Get("Authorization"))
if user == nil {
writeresult(w, codeError, nil, errInvalidToken.Error(), typeError)
return
}
docf := genfilecache.Get(*user.ID)
if docf == nil {
writeresult(w, codeError, nil, os.ErrNotExist.Error(), typeError)
return
}
_, _ = io.Copy(w, docf) _, _ = io.Copy(w, docf)
}} }}
} }

View File

@@ -53,7 +53,7 @@ func init() {
panic(err) panic(err)
} }
err = FileDB.db.Create(FileTableQuestion, &Question{}, err = FileDB.db.Create(FileTableQuestion, &Question{},
"FOREIGN KEY(FileID) REFERENCES "+FileTableFile+"(ID)", "FOREIGN KEY(ListID) REFERENCES "+FileTableList+"(ID)",
) )
if err != nil { if err != nil {
panic(err) panic(err)

View File

@@ -7,11 +7,13 @@ import (
sql "github.com/FloatTech/sqlite" sql "github.com/FloatTech/sqlite"
"github.com/fumiama/go-docx" "github.com/fumiama/go-docx"
"github.com/sirupsen/logrus"
) )
var ( var (
ErrInvalidGenerateConfig = errors.New("invalid generate config") ErrInvalidGenerateConfig = errors.New("invalid generate config")
ErrMajorTooLarge = errors.New("major too large") ErrMajorTooLarge = errors.New("major too large")
ErrNoSuchMajor = errors.New("no such major")
ErrNoEnoughQuestionToMatchRequire = errors.New("no enough question to match require") ErrNoEnoughQuestionToMatchRequire = errors.New("no enough question to match require")
ErrRateLimitExceeded = errors.New("rate limit exceeded") ErrRateLimitExceeded = errors.New("rate limit exceeded")
) )
@@ -26,21 +28,31 @@ type GenerateConfig struct {
} }
// GenerateFile 用一些限定条件生成新试卷, 云端不保存 // GenerateFile 用一些限定条件生成新试卷, 云端不保存
func (f *FileDatabase) GenerateFile(config *GenerateConfig) (*docx.Docx, error) { func (f *FileDatabase) GenerateFile(config *GenerateConfig) (docf *docx.Docx, err error) {
if config == nil || config.Distribution == nil || len(config.Distribution) == 0 { if config == nil || config.Distribution == nil || len(config.Distribution) == 0 {
return nil, ErrInvalidGenerateConfig return nil, ErrInvalidGenerateConfig
} }
if len(config.Distribution) > 10 { if len(config.Distribution) > 10 {
return nil, ErrMajorTooLarge return nil, ErrMajorTooLarge
} }
docf := docx.NewA4() mm := map[string]struct{}{}
for _, m := range f.GetMajors() {
mm[m] = struct{}{}
}
for n := range config.Distribution {
if _, ok := mm[n]; !ok {
return nil, ErrNoSuchMajor
}
}
docf = docx.NewA4()
f.mu.RLock() f.mu.RLock()
defer f.mu.RUnlock() defer f.mu.RUnlock()
i := 0
for n, c := range config.Distribution { for n, c := range config.Distribution {
if c == 0 { if c <= 0 {
continue continue
} }
docf.AddParagraph().AddText(string([]rune("一二三四五六七八九十")[c]) + "、" + n).Size("44").Bold() docf.AddParagraph().AddText(string([]rune("一二三四五六七八九十")[i]) + "、" + n).Size("30").Bold()
cond := " WHERE" cond := " WHERE"
hasfront := false hasfront := false
if config.YearStart > 0 { if config.YearStart > 0 {
@@ -51,20 +63,32 @@ func (f *FileDatabase) GenerateFile(config *GenerateConfig) (*docx.Docx, error)
if hasfront { if hasfront {
cond += " AND" cond += " AND"
} }
cond += " Year<=" + strconv.Itoa(int(config.YearStart)) cond += " Year<=" + strconv.Itoa(int(config.YearEnd))
hasfront = true hasfront = true
} }
if config.TypeMask > 0 {
if hasfront { if hasfront {
cond += " AND" cond += " AND"
} }
cond += " (Type&" + strconv.Itoa(int(config.TypeMask)) + ")!=0" typmsk := strconv.Itoa(int(config.TypeMask))
ques, err := sql.QueryAll[Question](&f.db, cond += " (Type&" + typmsk + ")==" + typmsk
"SELECT * FROM "+FileTableQuestion+ hasfront = true
" WHERE FileID IN (SELECT FileID FROM "+ }
FileTableFile+cond+ var ques []*Question
") ORDER BY RANDOM() limit "+strconv.Itoa(int(c))+";", q := ""
) if hasfront {
q = "SELECT * FROM " + FileTableQuestion +
" WHERE Major='" + n + "' AND ListID IN (SELECT DISTINCT ListID FROM " +
FileTableFile + cond +
") ORDER BY RANDOM() limit " + strconv.Itoa(int(c)) + ";"
ques, err = sql.QueryAll[Question](&f.db, q)
} else {
q = "SELECT * FROM " + FileTableQuestion +
" WHERE Major='" + n + "' ORDER BY RANDOM() limit " + strconv.Itoa(int(c)) + ";"
ques, err = sql.QueryAll[Question](&f.db, q)
}
if err != nil { if err != nil {
logrus.Warnln(err, q)
return nil, err return nil, err
} }
if len(ques) != int(c) { if len(ques) != int(c) {
@@ -79,7 +103,7 @@ func (f *FileDatabase) GenerateFile(config *GenerateConfig) (*docx.Docx, error)
return nil, ErrRateLimitExceeded return nil, ErrRateLimitExceeded
} }
for i, q := range ques { for i, q := range ques {
lst, err := sql.Find[List](&f.db, FileTableFile, "WHERE ID="+strconv.Itoa(q.ListID)) lst, err := sql.Find[List](&f.db, FileTableList, "WHERE ID="+strconv.Itoa(q.ListID))
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -98,6 +122,7 @@ func (f *FileDatabase) GenerateFile(config *GenerateConfig) (*docx.Docx, error)
docf.AddParagraph().AddText(strconv.Itoa(i+1) + ". (" + lst.Desc + ")") docf.AddParagraph().AddText(strconv.Itoa(i+1) + ". (" + lst.Desc + ")")
docf.AppendFile(docq) docf.AppendFile(docq)
} }
i++
} }
return docf, nil return docf, nil
} }

View File

@@ -1,5 +1,10 @@
import { defHttp, paperHttp } from '/@/utils/http/axios' import { defHttp, paperHttp } from '/@/utils/http/axios'
import { getFileListModel, AnalyzeFile, FileListGroupItem } from './model/fileListModel' import {
getFileListModel,
AnalyzeFile,
FileListGroupItem,
GenerateConfig,
} from './model/fileListModel'
import { DownloadFile, FileStatus } from './model/fileModel' import { DownloadFile, FileStatus } from './model/fileModel'
enum Api { enum Api {
@@ -11,6 +16,8 @@ enum Api {
DlFile = '/dlFile', DlFile = '/dlFile',
GetFileStatus = '/getFileStatus', GetFileStatus = '/getFileStatus',
GetMajors = '/getMajors', GetMajors = '/getMajors',
GenFile = '/genFile',
DlGen = '/dlGen',
} }
/** /**
@@ -81,3 +88,20 @@ export const getFileStatus = (id: number) => {
export const getMajors = () => { export const getMajors = () => {
return defHttp.get<string[]>({ url: Api.GetMajors }) return defHttp.get<string[]>({ url: Api.GetMajors })
} }
/**
* @description: Generate File
*/
export const generateFile = (config: GenerateConfig) => {
return defHttp.post<string>({ url: Api.GenFile, params: config }, { errorMessageMode: 'none' })
}
/**
* @description: Download generated file
*/
export const dlGeneratedFile = () => {
return paperHttp.get<any>(
{ url: '/api' + Api.DlGen, responseType: 'blob' },
{ errorMessageMode: 'none' },
)
}

View File

@@ -18,3 +18,11 @@ export interface AnalyzeFile {
code: number code: number
msg: string msg: string
} }
export interface GenerateConfig {
Distribution: { [x: string]: any } // Distribution is map[majorname]subcount
RateLimit: number // RateLimit 重复率上限
YearStart: number // YearStart 起始年份(空则直到最旧)
YearEnd: number // YearEnd 截止年份(空则直到最新)
TypeMask: number // TypeMask & File.Type != 0 则匹配
}

View File

@@ -95,7 +95,12 @@
async function customSubmitFunc() { async function customSubmitFunc() {
try { try {
const values = await validate() const values = await validate()
emit('next', values) const data = getDataSource()
if (data.length == 0) {
createMessage.error('必须指定至少一种题型!')
return
}
emit('next', { values, data })
} catch (error) {} } catch (error) {}
} }

View File

@@ -1,11 +1,36 @@
<template> <template>
<div class="step2"> <div class="step2">
<a-alert message="确认转账后,资金将直接打入对方账户,无法退回。" show-icon /> <a-alert message="确认提交后,将进入下载页面,云端不保存。" show-icon />
<a-descriptions :column="1" class="mt-5"> <a-descriptions :column="1" class="mt-5">
<a-descriptions-item label="付款账户"> ant-design@alipay.com </a-descriptions-item> <a-descriptions-item label="大题数">
<a-descriptions-item label="收款账户"> test@example.com </a-descriptions-item> {{ state.step1Values.data.length }}
<a-descriptions-item label="收款人姓名"> Vben </a-descriptions-item> </a-descriptions-item>
<a-descriptions-item label="转账金额"> 500 </a-descriptions-item> <a-descriptions-item label="小题数">
{{
state.step1Values.data
.map((record) => record.count || 0)
.reduce((sum, count) => sum + count, 0)
}}
</a-descriptions-item>
<a-descriptions-item label="重复率上限">
{{ state.step1Values.values.RateLimit[1] / 100 }}
</a-descriptions-item>
<a-descriptions-item label="起止年份" v-if="state.step1Values.values['[YearStart, YearEnd]']">
{{ state.step1Values.values['[YearStart, YearEnd]'][0].$y }} -
{{ state.step1Values.values['[YearStart, YearEnd]'][1].$y }}
</a-descriptions-item>
<a-descriptions-item label="试卷类别" v-if="state.step1Values.values.AB">
{{ state.step1Values.values.AB }}
</a-descriptions-item>
<a-descriptions-item label="考试阶段" v-if="state.step1Values.values.MiddleFinal">
{{ state.step1Values.values.MiddleFinal }}
</a-descriptions-item>
<a-descriptions-item label="考试学期" v-if="state.step1Values.values.FirstSecond">
{{ state.step1Values.values.FirstSecond }}
</a-descriptions-item>
<a-descriptions-item label="考试类型" v-if="state.step1Values.values.OpenClose">
{{ state.step1Values.values.OpenClose }}
</a-descriptions-item>
</a-descriptions> </a-descriptions>
<a-divider /> <a-divider />
<BasicForm @register="register" /> <BasicForm @register="register" />
@@ -14,7 +39,9 @@
<script lang="ts"> <script lang="ts">
import { defineComponent } from 'vue' import { defineComponent } from 'vue'
import { BasicForm, useForm } from '/@/components/Form' import { BasicForm, useForm } from '/@/components/Form'
import { step2Schemas } from './data' import { state } from './data'
import { generateFile } from '/@/api/page'
import { useMessage } from '/@/hooks/web/useMessage'
import { Alert, Divider, Descriptions } from 'ant-design-vue' import { Alert, Divider, Descriptions } from 'ant-design-vue'
export default defineComponent({ export default defineComponent({
@@ -27,11 +54,10 @@
}, },
emits: ['next', 'prev'], emits: ['next', 'prev'],
setup(_, { emit }) { setup(_, { emit }) {
const [register, { validate, setProps }] = useForm({ const [register, { setProps }] = useForm({
labelWidth: 80, labelWidth: 80,
schemas: step2Schemas,
actionColOptions: { actionColOptions: {
span: 14, span: 16,
}, },
resetButtonOptions: { resetButtonOptions: {
text: '上一步', text: '上一步',
@@ -43,30 +69,74 @@
submitFunc: customSubmitFunc, submitFunc: customSubmitFunc,
}) })
const { createMessage } = useMessage()
async function customResetFunc() { async function customResetFunc() {
emit('prev') emit('prev')
} }
async function customSubmitFunc() { async function customSubmitFunc() {
try { try {
const values = await validate()
setProps({ setProps({
submitButtonOptions: { submitButtonOptions: {
loading: true, loading: true,
}, },
}) })
setTimeout(() => { let ys = 0
let ye = 0
if (state.step1Values.values['[YearStart, YearEnd]']) {
ys = state.step1Values.values['[YearStart, YearEnd]'][0].$y
ye = state.step1Values.values['[YearStart, YearEnd]'][1].$y
}
let tm = 0
if (state.step1Values.values.AB) {
if (state.step1Values.values.AB == 'A') tm |= 1
else if (state.step1Values.values.AB == 'B') tm |= 2
}
if (state.step1Values.values.MiddleFinal) {
if (state.step1Values.values.MiddleFinal == '中') tm |= 1 << 4
else if (state.step1Values.values.MiddleFinal == '末') tm |= 2 << 4
}
if (state.step1Values.values.FirstSecond) {
if (state.step1Values.values.FirstSecond == '1') tm |= 1 << 8
else if (state.step1Values.values.FirstSecond == '2') tm |= 2 << 8
}
if (state.step1Values.values.OpenClose) {
if (state.step1Values.values.OpenClose == '开卷') tm |= 1 << 12
else if (state.step1Values.values.OpenClose == '一页纸开卷') tm |= 2 << 12
else if (state.step1Values.values.OpenClose == '闭卷') tm |= 4 << 12
}
const data = await generateFile({
Distribution: state.step1Values.data.reduce((acc, { major, count }) => {
console.log(major, count)
acc[major] = count
return acc
}, {}),
RateLimit: state.step1Values.values.RateLimit[1] / 100,
YearStart: ys,
YearEnd: ye,
TypeMask: tm,
})
setProps({ setProps({
submitButtonOptions: { submitButtonOptions: {
loading: false, loading: false,
}, },
}) })
emit('next', values) emit('next', data)
}, 1500) } catch (error) {
} catch (error) {} createMessage.error((error as unknown as Error).message)
setProps({
submitButtonOptions: {
loading: false,
},
})
}
} }
return { register } return {
register,
state,
}
}, },
}) })
</script> </script>

View File

@@ -1,36 +1,87 @@
<template> <template>
<div class="step3"> <div class="step3">
<a-result status="success" title="操作成功" sub-title="预计两小时内到账"> <a-result status="success" title="试卷生成成功" :sub-title="msg">
<template #extra> <template #extra>
<a-button type="primary" @click="redo"> 转一笔 </a-button> <a-button type="primary" @click="redo"> 次生成 </a-button>
<a-button> 查看账单 </a-button> <a-button @click="downloadDocx"> 下载试卷 </a-button>
</template> </template>
</a-result> </a-result>
<div class="desc-wrap"> <div class="docxWrap" :style="{ width }">
<a-descriptions :column="1" class="mt-5"> <div ref="docxRef"></div>
<a-descriptions-item label="付款账户"> ant-design@alipay.com </a-descriptions-item>
<a-descriptions-item label="收款账户"> test@example.com </a-descriptions-item>
<a-descriptions-item label="收款人姓名"> Vben </a-descriptions-item>
<a-descriptions-item label="转账金额"> 500 </a-descriptions-item>
</a-descriptions>
</div> </div>
</div> </div>
</template> </template>
<script lang="ts"> <script lang="ts">
import { defineComponent } from 'vue' import { defineComponent, ref } from 'vue'
import { Result, Descriptions } from 'ant-design-vue' import { Result, Descriptions } from 'ant-design-vue'
import { renderAsync } from 'docx-preview'
import { state } from './data'
import { dlGeneratedFile } from '/@/api/page'
import { useMessage } from '/@/hooks/web/useMessage'
import { downloadByData } from '/@/utils/file/download'
let docxRef = ref(null)
let docxNameRef = ref('paper.docx')
let docxSizeRef = ref(0)
let docxBlob: Blob | null = null
function loadDocx(file: Blob) {
docxBlob = file
renderAsync(file, docxRef.value as unknown as HTMLElement, undefined, {
className: 'docx', // 默认和文档样式类的类名/前缀
inWrapper: false, // 启用围绕文档内容渲染包装器
ignoreWidth: false, // 禁止页面渲染宽度
ignoreHeight: false, // 禁止页面渲染高度
ignoreFonts: false, // 禁止字体渲染
breakPages: false, // 在分页符上启用分页
ignoreLastRenderedPageBreak: true, //禁用lastRenderedPageBreak元素的分页
experimental: true, // 启用实验性功能(制表符停止计算)
trimXmlDeclaration: true, // 如果为真xml声明将在解析之前从xml文档中删除
debug: false, // 启用额外的日志记录
})
}
function downloadDocx() {
downloadByData(docxBlob as BlobPart, docxNameRef.value)
}
export default defineComponent({ export default defineComponent({
components: { components: {
[Result.name]: Result, [Result.name]: Result,
[Descriptions.name]: Descriptions, [Descriptions.name]: Descriptions,
[Descriptions.Item.name]: Descriptions.Item, [Descriptions.Item.name]: Descriptions.Item,
}, },
props: {
width: {
type: String as PropType<string>,
default: '100%',
},
},
emits: ['redo'], emits: ['redo'],
setup(_, { emit }) { setup(_, { emit }) {
const { createMessage } = useMessage()
;(async () => {
try {
const data = await dlGeneratedFile()
if (data) {
loadDocx(data)
return
}
} catch (error) {
createMessage.error('加载docx错误: ' + (error as unknown as Error).message)
}
})()
return { return {
msg: state.step2Values,
redo: () => { redo: () => {
emit('redo') emit('redo')
}, },
docxRef,
downloadDocx,
docxNameRef,
docxSizeRef,
} }
}, },
}) })
@@ -41,9 +92,12 @@
margin: 0 auto; margin: 0 auto;
} }
.desc-wrap { .docxWrap {
padding: 24px 40px; padding-top: 0px;
margin-top: 24px; margin: 0 auto;
background-color: @background-color-light; overflow-x: auto;
display: grid;
align-items: center;
justify-items: center;
} }
</style> </style>

View File

@@ -1,4 +1,12 @@
import { FormSchema } from '/@/components/Form' import { FormSchema } from '/@/components/Form'
import { reactive } from 'vue'
export const state = reactive({
initSetp2: false,
initSetp3: false,
step1Values: { values: {} as any, data: [] as Recordable<any>[] },
step2Values: {} as any,
})
export const step1Schemas: FormSchema[] = [ export const step1Schemas: FormSchema[] = [
{ {
@@ -119,16 +127,3 @@ export const step1Schemas: FormSchema[] = [
}, },
}, },
] ]
export const step2Schemas: FormSchema[] = [
{
field: 'pwd',
component: 'InputPassword',
label: '支付密码',
required: true,
defaultValue: '123456',
colProps: {
span: 24,
},
},
]

View File

@@ -1,15 +1,15 @@
<template> <template>
<PageWrapper <PageWrapper
title="分步表单" :title="t('routes.genfile.name')"
contentBackground contentBackground
content=" 将一个冗长或用户不熟悉的表单任务分成多个步骤,指导用户完成。" content="使用自定义限制条件生成试卷"
contentClass="p-4" contentClass="p-4"
> >
<div class="step-form-form"> <div class="step-form-form">
<a-steps :current="current"> <a-steps :current="current">
<a-step title="填写转账信息" /> <a-step title="填写信息" />
<a-step title="确认转账信息" /> <a-step title="确认生成" />
<a-step title="完成" /> <a-step title="下载" />
</a-steps> </a-steps>
</div> </div>
<div class="mt-5"> <div class="mt-5">
@@ -25,12 +25,14 @@
</PageWrapper> </PageWrapper>
</template> </template>
<script lang="ts"> <script lang="ts">
import { defineComponent, ref, reactive, toRefs } from 'vue' import { defineComponent, ref, toRefs } from 'vue'
import { state } from './data'
import Step1 from './Step1.vue' import Step1 from './Step1.vue'
import Step2 from './Step2.vue' import Step2 from './Step2.vue'
import Step3 from './Step3.vue' import Step3 from './Step3.vue'
import { PageWrapper } from '/@/components/Page' import { PageWrapper } from '/@/components/Page'
import { Steps } from 'ant-design-vue' import { Steps } from 'ant-design-vue'
import { useI18n } from '/@/hooks/web/useI18n'
export default defineComponent({ export default defineComponent({
name: 'FormStepPage', name: 'FormStepPage',
@@ -45,15 +47,12 @@
setup() { setup() {
const current = ref(0) const current = ref(0)
const state = reactive({ const { t } = useI18n()
initSetp2: false,
initSetp3: false,
})
function handleStep1Next(step1Values: any) { function handleStep1Next(step1Values: any) {
current.value++ current.value++
state.initSetp2 = true state.initSetp2 = true
console.log(step1Values) state.step1Values = step1Values
} }
function handleStepPrev() { function handleStepPrev() {
@@ -63,7 +62,7 @@
function handleStep2Next(step2Values: any) { function handleStep2Next(step2Values: any) {
current.value++ current.value++
state.initSetp3 = true state.initSetp3 = true
console.log(step2Values) state.step2Values = step2Values
} }
function handleRedo() { function handleRedo() {
@@ -73,6 +72,7 @@
} }
return { return {
t,
current, current,
handleStep1Next, handleStep1Next,
handleStep2Next, handleStep2Next,