chore(小程序): 初始化工程仓库
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
VITE_PUBLIC_API_BASE=https://api.your-domain.cn
|
||||
VITE_LOCAL_API_BASE=https://store-001.lan.your-domain.cn
|
||||
VITE_STORE_ID=store-001
|
||||
# 仅本地开发时设置;生产构建始终关闭开发登录
|
||||
VITE_DEV_LOGIN=false
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
.DS_Store
|
||||
node_modules/
|
||||
dist/
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
!.env.example
|
||||
*.log
|
||||
*.tsbuildinfo
|
||||
coverage/
|
||||
@@ -0,0 +1,14 @@
|
||||
# 顾客端
|
||||
|
||||
uni-app + Vue 3 + TypeScript。完整启动、双域名部署与真机配置见 [根目录说明](../README.md)。
|
||||
|
||||
```sh
|
||||
npm ci
|
||||
VITE_DEV_LOGIN=true npm run dev:h5
|
||||
npm test
|
||||
npm run typecheck
|
||||
npm run build:h5
|
||||
npm run build:mp-weixin
|
||||
```
|
||||
|
||||
发布前配置 `.env.production` 和 `src/manifest.json` 的微信 AppID;密钥只配置在服务端。演示模式仅在开发构建显式开启。
|
||||
@@ -0,0 +1 @@
|
||||
<!doctype html><html lang="zh-CN"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover"><meta name="robots" content="noindex,nofollow"><title>豆间 · 拼豆工坊</title></head><body><div id="app"></div><script type="module" src="/src/main.ts"></script></body></html>
|
||||
Generated
+9235
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,49 @@
|
||||
{
|
||||
"name": "perler-beads-applet",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev:h5": "uni --host 127.0.0.1 --port 5173",
|
||||
"dev:mp-weixin": "uni -p mp-weixin",
|
||||
"build:h5": "uni build",
|
||||
"build:mp-weixin": "uni build -p mp-weixin",
|
||||
"typecheck": "vue-tsc --noEmit",
|
||||
"test": "tsx --test tests/*.test.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@dcloudio/uni-app": "3.0.0-5020620260917001",
|
||||
"@dcloudio/uni-components": "3.0.0-5020620260917001",
|
||||
"@dcloudio/uni-h5": "3.0.0-5020620260917001",
|
||||
"@dcloudio/uni-mp-weixin": "3.0.0-5020620260917001",
|
||||
"vue": "3.4.38",
|
||||
"pinia": "2.2.2",
|
||||
"vue-i18n": "9.14.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@dcloudio/types": "3.4.31",
|
||||
"@dcloudio/vite-plugin-uni": "3.0.0-5020620260917001",
|
||||
"@dcloudio/uni-cli-shared": "3.0.0-5020620260917001",
|
||||
"@types/node": "^22.0.0",
|
||||
"typescript": "~5.6.3",
|
||||
"vue-tsc": "2.1.10",
|
||||
"vite": "5.4.21",
|
||||
"tsx": "^4.20.0"
|
||||
},
|
||||
"overrides": {
|
||||
"vite": "5.4.21",
|
||||
"ws": "8.22.0",
|
||||
"jpeg-js": "0.4.4",
|
||||
"postcss": "8.5.28",
|
||||
"postcss-selector-parser": "6.1.4",
|
||||
"express": "4.22.3",
|
||||
"adm-zip": "0.6.1",
|
||||
"phin": "3.7.1",
|
||||
"@babel/core": "7.29.7",
|
||||
"@intlify/core-base": "9.14.5",
|
||||
"@intlify/shared": "9.14.5",
|
||||
"@intlify/message-compiler": "9.14.5",
|
||||
"@intlify/message-resolver": "9.14.5",
|
||||
"@intlify/vue-devtools": "9.14.5"
|
||||
}
|
||||
}
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import { onLaunch } from '@dcloudio/uni-app'
|
||||
import { listenNetwork } from './services/api'
|
||||
onLaunch(listenNetwork)
|
||||
</script>
|
||||
<style>
|
||||
page{--ink:#253d32;--muted:#697369;--surface:#fffdf7;--line:#e1e3d8;background:#f7f5ee;color:#253d32;font-family:-apple-system,"PingFang SC","Microsoft YaHei",sans-serif;font-size:14px;line-height:1.6}view,text{box-sizing:border-box}button{font-size:14px;border-radius:8px;margin:0;background:#fffdf7;color:#265b49;line-height:2.8}button::after{border:1px solid #d8ddd2}button.primary{background:#265b49;color:#fffdf7}button.primary::after{border:0}button[disabled]{opacity:.5}button:focus-visible{outline:3px solid #d78b40;outline-offset:2px}input{padding:10px 12px;background:#fff;border:1px solid #d8ddd2;border-radius:6px;min-height:24px}textarea{background:#fff;border:1px solid #d8ddd2;padding:12px;width:100%;border-radius:6px}.page{padding:24px 20px 36px;max-width:720px;margin:auto}.eyebrow{font-size:11px;letter-spacing:3px;color:#657868;text-transform:uppercase}.title{font-size:28px;font-weight:700;line-height:1.3;margin:7px 0 12px}.muted{color:#697369;font-size:12px}.section-title{font-size:18px;font-weight:600;margin:24px 0 12px}.panel{padding:18px;background:#fffdf7;border:1px solid #e1e3d8;border-radius:8px;margin-bottom:16px}.row{display:flex;align-items:center;gap:10px}.spread{justify-content:space-between}.wrap{flex-wrap:wrap}.stack{display:flex;flex-direction:column;gap:12px}.grow{flex:1;min-width:0}.label{display:block;margin:12px 0 6px;font-weight:600}.chip{display:inline-block;padding:4px 10px;border-radius:4px;background:#e7ede1;font-size:12px}.error{color:#a13929;background:#fff1e9;padding:12px;border-radius:6px;margin:12px 0}.empty{padding:36px 12px;text-align:center;color:#687668}.grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px}.art{background:#ecefe2;border:1px solid #e0e4d8;border-radius:8px;overflow:hidden}.art-info{padding:12px;background:#fffdf7}.art-title{font-weight:600}.swatch{display:inline-block;width:20px;height:20px;border:1px solid #aaa;border-radius:3px;vertical-align:middle}.divider{height:1px;background:#e0e4d8;margin:18px 0}.hint{padding:12px;border-left:3px solid #b48647;background:#f0eadc;font-size:12px}.tap{cursor:pointer}.small{font-size:12px}.danger{color:#a13929}.toolbar{position:sticky;bottom:0;background:#f7f5ee;padding:12px 0;z-index:5}.mt{margin-top:16px}
|
||||
|
||||
.page{padding:24px 20px calc(36px + env(safe-area-inset-bottom));max-width:600px}.page-heading{margin-bottom:28px}.title{font-size:27px;letter-spacing:.02em}.settings-group{background:var(--surface);border:1px solid var(--line);border-radius:12px;overflow:hidden;margin:12px 0 24px}.settings-group>view+view,.settings-group>button+button{border-top:1px solid var(--line)}.settings-group .setting-row{border-bottom:1px solid var(--line)}.settings-group>view:last-child .setting-row,.settings-group>.setting-row:last-child{border-bottom:0}.section-title{font-size:14px;color:#596957;letter-spacing:.04em;margin:24px 2px 10px}.primary{min-height:46px}.action-footer{margin-top:28px;display:flex;flex-direction:column;gap:12px;padding-bottom:env(safe-area-inset-bottom)}.page-note{font-size:12px;line-height:1.8;color:var(--muted);margin:16px 4px}.empty-title{font-size:17px;color:var(--ink);margin-bottom:8px}.hero-stamp{font-family:Georgia,serif;font-size:44px;letter-spacing:-3px;color:#b0bba1;line-height:1}.entry-heading{display:flex;align-items:center;justify-content:space-between;gap:16px}.panel{border-radius:12px}.settings-group+.page-note{margin-top:-12px}button:active{opacity:.85}@media(max-width:360px){.page{padding-left:16px;padding-right:16px}.title{font-size:24px}.setting-row{padding:14px}}@media(prefers-reduced-motion:reduce){*{transition:none!important;animation:none!important}}
|
||||
</style>
|
||||
@@ -0,0 +1,36 @@
|
||||
<script setup lang="ts">
|
||||
import { computed,getCurrentInstance,nextTick,onMounted,watch } from 'vue'
|
||||
import { cellAtPoint } from '../core/canvas'
|
||||
import type { Pattern } from '../core/types'
|
||||
let props=withDefaults(defineProps<{pattern:Pattern;size?:number;highlight?:string;grid?:boolean;completed?:number[]}>(),{size:160,highlight:'',grid:false,completed:()=>[]})
|
||||
let emit=defineEmits<{cell:[index:number]}>()
|
||||
let instance=getCurrentInstance(); let id='p'+instance?.uid
|
||||
let height=computed(()=>Math.round(props.size*props.pattern.height/props.pattern.width))
|
||||
async function draw(){await nextTick(); let p=props.pattern;let ctx=uni.createCanvasContext(id,instance);let s=props.size/p.width
|
||||
ctx.clearRect(0,0,props.size,height.value);ctx.setFillStyle('#f0efe5');ctx.fillRect(0,0,props.size,height.value)
|
||||
let palette=new Map(p.colors.map(c=>[c.code,c.hex]));let done=new Set(props.completed)
|
||||
p.cells.forEach((c,i)=>{if(!c)return;ctx.setGlobalAlpha(props.highlight&&c!==props.highlight? .15:1);ctx.setFillStyle(palette.get(c)||'#777');let x=i%p.width*s,y=Math.floor(i/p.width)*s;ctx.fillRect(x,y,s,s);ctx.setGlobalAlpha(1)
|
||||
if(props.grid&&s>=24){ctx.setFillStyle('#fff');ctx.fillRect(x+1,y+1,s-2,11);ctx.setFillStyle('#253d32');ctx.setFontSize(8);ctx.fillText(c,x+2,y+9)}
|
||||
if(done.has(i)){ctx.setFillStyle('rgba(255,255,255,0.67)');ctx.fillRect(x,y,s,s);ctx.setFillStyle('#265b49');ctx.setFontSize(Math.max(9,s*.6));ctx.fillText('✓',x+s*.15,y+s*.8)}
|
||||
});
|
||||
if(props.grid){
|
||||
ctx.setStrokeStyle('rgba(100,119,98,0.24)');ctx.setLineWidth(.5);ctx.beginPath()
|
||||
for(let column=0;column<=p.width;column++){ctx.moveTo(column*s,0);ctx.lineTo(column*s,height.value)}
|
||||
for(let row=0;row<=p.height;row++){ctx.moveTo(0,row*s);ctx.lineTo(props.size,row*s)}
|
||||
ctx.stroke()
|
||||
}
|
||||
ctx.draw()
|
||||
}
|
||||
function tap(e:any){
|
||||
let touch=e.changedTouches?.[0]||e.touches?.[0]
|
||||
let x=touch?.clientX??e.detail?.x,y=touch?.clientY??e.detail?.y
|
||||
// 事件坐标属于视口,必须扣除画布在滚动容器中的实际偏移。
|
||||
uni.createSelectorQuery().in(instance?.proxy).select('#'+id).boundingClientRect((value:any)=>{
|
||||
if(!value)return
|
||||
let cell=cellAtPoint(x,y,value,props.pattern.width,props.pattern.height)
|
||||
if(cell!==null)emit('cell',cell)
|
||||
}).exec()
|
||||
}
|
||||
onMounted(draw);watch(()=>[props.pattern,props.size,props.highlight,props.completed],draw,{deep:true})
|
||||
</script>
|
||||
<template><canvas :id="id" :canvas-id="id" :style="{width:size+'px',height:height+'px'}" :aria-label="pattern.title+','+pattern.width+'乘'+pattern.height+'格'" @tap="tap" /></template>
|
||||
@@ -0,0 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import {ref,watch} from 'vue'
|
||||
import SheetShell from './SheetShell.vue'
|
||||
let props=withDefaults(defineProps<{open:boolean;title:string;options:{value:string;label:string;description?:string;color?:string}[];selected?:string[];multiple?:boolean}>(),{selected:()=>[],multiple:false})
|
||||
let emit=defineEmits<{close:[];confirm:[values:string[]]}>(),draft=ref<string[]>([])
|
||||
watch(()=>props.open,value=>{if(value)draft.value=[...props.selected]},{immediate:true})
|
||||
function choose(value:string){draft.value=props.multiple?(draft.value.includes(value)?draft.value.filter(v=>v!==value):[...draft.value,value]):[value]}
|
||||
</script>
|
||||
<template><SheetShell :open="open" :title="title" @close="emit('close')"><scroll-view scroll-y class="choice-list"><button v-for="option in options" :key="option.value" class="choice-row" role="button" :aria-pressed="draft.includes(option.value)" tabindex="0" :aria-label="option.label+(draft.includes(option.value)?',已选':'')" @tap="choose(option.value)" @keydown.enter.prevent="choose(option.value)" @keydown.space.prevent="choose(option.value)"><text v-if="option.color" class="swatch" :style="{background:option.color}"/><view class="choice-copy"><text>{{option.label}}</text><text v-if="option.description" class="muted">{{option.description}}</text></view><text class="choice-check" :class="{selected:draft.includes(option.value)}">{{draft.includes(option.value)?'✓':'○'}}</text></button></scroll-view><button class="primary choice-confirm" role="button" tabindex="0" :disabled="!multiple&&!draft.length" @tap="emit('confirm',draft)" @keydown.enter.prevent="(multiple||draft.length)&&emit('confirm',draft)" @keydown.space.prevent="(multiple||draft.length)&&emit('confirm',draft)">确认{{multiple?' · '+draft.length+' 项':''}}</button></SheetShell></template>
|
||||
<style scoped>.choice-list{max-height:48vh;min-height:64px;flex-shrink:1}.choice-row{display:flex;align-items:center;gap:12px;border-radius:0;text-align:left;background:transparent;min-height:56px;padding:12px 0;line-height:1.5}.choice-row::after{border:0;border-bottom:1px solid #e6e8df}.choice-copy{flex:1;min-width:0;display:flex;flex-direction:column;gap:4px}.choice-check{color:#8d968a;font-size:20px}.choice-check.selected{color:#265b49}.choice-confirm{margin-top:20px;flex-shrink:0}</style>
|
||||
@@ -0,0 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import {ref,watch} from 'vue'
|
||||
import SheetShell from './SheetShell.vue'
|
||||
let props=withDefaults(defineProps<{open:boolean;title:string;value?:string|number;placeholder?:string;type?:'text'|'number'|'digit'|'textarea';maxlength?:number}>(),{value:'',type:'text',maxlength:200})
|
||||
let emit=defineEmits<{close:[];confirm:[value:string]}>(),draft=ref('')
|
||||
watch(()=>props.open,value=>{if(value)draft.value=String(props.value)},{immediate:true})
|
||||
</script>
|
||||
<template><SheetShell :open="open" :title="title" @close="emit('close')"><textarea v-if="type==='textarea'" v-model="draft" class="dialog-textarea" :aria-label="title" :placeholder="placeholder" :maxlength="maxlength" :focus="open"/><input v-else v-model="draft" :type="type" :aria-label="title" :placeholder="placeholder" :maxlength="maxlength" :focus="open" @confirm="emit('confirm',draft)"/><view class="dialog-actions"><button role="button" tabindex="0" @tap="emit('close')" @keydown.enter.prevent="emit('close')" @keydown.space.prevent="emit('close')">取消</button><button class="primary" role="button" tabindex="0" @tap="emit('confirm',draft)" @keydown.enter.prevent="emit('confirm',draft)" @keydown.space.prevent="emit('confirm',draft)">确认</button></view></SheetShell></template>
|
||||
<style scoped>.dialog-textarea{height:120px}.dialog-actions{display:flex;gap:12px;margin-top:24px}.dialog-actions button{flex:1}</style>
|
||||
@@ -0,0 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import {backOrMain,goMain,type MainTab} from '../../core/navigation'
|
||||
defineProps<{tab:MainTab}>()
|
||||
let labels={gallery:'图纸首页',make:'制作首页',store:'门店首页',mine:'我的首页'}
|
||||
</script>
|
||||
<template><view class="page-return"><button class="text-button" role="button" tabindex="0" @tap="backOrMain(tab)" @keydown.enter.prevent="backOrMain(tab)" @keydown.space.prevent="backOrMain(tab)">‹ 返回上页</button><button class="text-button" role="button" tabindex="0" @tap="goMain(tab)" @keydown.enter.prevent="goMain(tab)" @keydown.space.prevent="goMain(tab)">{{labels[tab]}} ↗</button></view></template>
|
||||
<style scoped>.page-return{display:flex;justify-content:space-between;gap:12px;margin:-8px -8px 20px}.text-button{background:transparent;font-size:12px;padding:4px 8px;min-height:44px;line-height:28px}.text-button::after{border:0}</style>
|
||||
@@ -0,0 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{label:string;value?:string|number;description?:string;disabled?:boolean;danger?:boolean}>()
|
||||
defineEmits<{activate:[]}>()
|
||||
</script>
|
||||
<template><button class="setting-row" role="button" :tabindex="disabled?-1:0" :aria-disabled="!!disabled" @keydown.enter.prevent="!disabled&&$emit('activate')" @keydown.space.prevent="!disabled&&$emit('activate')" :class="{danger}" :disabled="disabled" :aria-label="label+(value?','+value:'')" @tap="$emit('activate')"><view class="setting-copy"><text class="setting-label">{{label}}</text><text v-if="description" class="setting-description">{{description}}</text></view><text v-if="value!==undefined&&value!==''" class="setting-value">{{value}}</text><text class="setting-arrow" aria-hidden="true">›</text></button></template>
|
||||
<style scoped>.setting-row{display:flex;align-items:center;gap:12px;width:100%;min-height:64px;text-align:left;border-radius:0;padding:16px;background:transparent;line-height:1.5}.setting-row::after{border:0}.setting-copy{flex:1;min-width:0;display:flex;flex-direction:column;gap:4px}.setting-label{font-size:15px;color:var(--ink)}.setting-description{font-size:12px;color:var(--muted)}.setting-value{font-size:13px;color:var(--muted);max-width:45%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.setting-arrow{font-size:24px;color:#8b9689;line-height:1}.danger .setting-label{color:#a13929}</style>
|
||||
@@ -0,0 +1,19 @@
|
||||
<script setup lang="ts">
|
||||
import {watch,nextTick,onUnmounted,getCurrentInstance} from 'vue'
|
||||
let props=defineProps<{open:boolean;title:string}>(),emit=defineEmits<{close:[]}>()
|
||||
let uid='sheet-'+getCurrentInstance()?.uid
|
||||
// #ifdef H5
|
||||
let previous:HTMLElement|null=null
|
||||
function key(event:KeyboardEvent){
|
||||
if(event.key==='Escape'){event.preventDefault();emit('close')}
|
||||
if(event.key!=='Tab')return
|
||||
let nodes=Array.from(document.getElementById(uid)?.querySelectorAll<HTMLElement>('input,textarea,[tabindex="0"]')||[]).filter(n=>!n.hasAttribute('disabled'))
|
||||
let first=nodes[0],last=nodes[nodes.length-1]
|
||||
if(event.shiftKey&&document.activeElement===first){event.preventDefault();last?.focus()}else if(!event.shiftKey&&document.activeElement===last){event.preventDefault();first?.focus()}
|
||||
}
|
||||
watch(()=>props.open,async open=>{if(open){previous=document.activeElement as HTMLElement;document.addEventListener('keydown',key);await nextTick();document.getElementById(uid)?.querySelector<HTMLElement>('input,textarea,[tabindex="0"]')?.focus()}else{document.removeEventListener('keydown',key);previous?.focus()}},{immediate:true})
|
||||
onUnmounted(()=>document.removeEventListener('keydown',key))
|
||||
// #endif
|
||||
</script>
|
||||
<template><view v-if="open" class="sheet-mask" @tap="emit('close')" @touchmove.stop.prevent><view :id="uid" class="sheet" role="dialog" aria-modal="true" :aria-label="title" @tap.stop @touchmove.stop><view class="sheet-heading"><text class="sheet-title">{{title}}</text><button class="sheet-close" role="button" aria-label="关闭对话" tabindex="0" @tap="emit('close')" @keydown.enter.prevent="emit('close')" @keydown.space.prevent="emit('close')">×</button></view><slot/></view></view></template>
|
||||
<style scoped>.sheet-mask{position:fixed;inset:0;background:rgba(25,40,31,.36);z-index:1000;display:flex;align-items:flex-end;justify-content:center;padding:12px}.sheet{width:100%;max-width:520px;max-height:85vh;overflow:hidden;display:flex;flex-direction:column;background:#fffdf8;border-radius:18px;padding:20px 20px calc(20px + env(safe-area-inset-bottom))}.sheet-heading{display:flex;justify-content:space-between;align-items:center;gap:16px;margin-bottom:20px}.sheet-title{font-size:19px;font-weight:600}.sheet-close{font-size:26px;line-height:36px;width:44px;height:44px;padding:0;background:#f0f1e9;flex-shrink:0}.sheet-close::after{border:0}@media(min-width:600px){.sheet-mask{align-items:center}}</style>
|
||||
@@ -0,0 +1,5 @@
|
||||
export function cellAtPoint(x:number,y:number,rect:{left:number;top:number;width:number;height:number},width:number,height:number):number|null{
|
||||
if(!Number.isFinite(x)||!Number.isFinite(y)||rect.width<=0||rect.height<=0)return null
|
||||
let col=Math.floor((x-rect.left)/rect.width*width),row=Math.floor((y-rect.top)/rect.height*height)
|
||||
return col>=0&&col<width&&row>=0&&row<height ? row*width+col : null
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
import type {Pattern,Color} from './types'
|
||||
export function creationSize(value:string|number){let size=Number(value);if(!String(value).trim()||!Number.isInteger(size)||size<8||size>96)throw new Error('宽高请输入 8–96 之间的整数');return size}
|
||||
export function readDraft(value:unknown):Pattern{let p=value as Pattern;if(!p||typeof p.title!=='string'||!Array.isArray(p.cells)||!Array.isArray(p.colors))throw new Error('草稿内容不完整');let w=creationSize(p.width),h=creationSize(p.height);let codes=new Set(p.colors.map((c:Color)=>c.code));if(p.cells.length!==w*h||p.cells.some(c=>c!==null&&(typeof c!=='string'||!codes.has(c))))throw new Error('草稿网格已损坏');return {...p,id:'draft',width:w,height:h,cells:[...p.cells],colors:p.colors.map(c=>({...c})),beadCount:p.cells.filter(c=>c!==null).length,visibility:'private'}}
|
||||
@@ -0,0 +1,10 @@
|
||||
export type MainTab='gallery'|'make'|'store'|'mine'
|
||||
export function mainRoute(tab:MainTab){return '/pages/'+tab+'/index'}
|
||||
export function route(path:string,query:Record<string,string|number|undefined>={}){
|
||||
let parts=Object.entries(query).filter(([,v])=>v!==undefined).map(([k,v])=>encodeURIComponent(k)+'='+encodeURIComponent(String(v)))
|
||||
return '/'+path.replace(/^\//,'')+(parts.length?'?'+parts.join('&'):'')
|
||||
}
|
||||
export function returnAction(depth:number,tab:MainTab):{type:'back'}|{type:'tab';url:string}{return depth>1?{type:'back'}:{type:'tab',url:mainRoute(tab)}}
|
||||
export function goMain(tab:MainTab){uni.switchTab({url:mainRoute(tab)})}
|
||||
export function goPage(path:string,query:Record<string,string|number|undefined>={}){uni.navigateTo({url:route(path,query),fail:()=>uni.showToast({title:'页面暂时无法打开,请重试',icon:'none'})})}
|
||||
export function backOrMain(tab:MainTab){let action=returnAction(getCurrentPages().length,tab);if(action.type==='back')uni.navigateBack({fail:()=>goMain(tab)});else goMain(tab)}
|
||||
@@ -0,0 +1,29 @@
|
||||
export interface EndpointConfig { local: string; remote: string; storeId: string }
|
||||
export class EndpointRouter {
|
||||
current: string
|
||||
private epoch = 0
|
||||
private type = ''
|
||||
private retryAt = 0
|
||||
private pending: Promise<string> | null = null
|
||||
private config: EndpointConfig
|
||||
private probe: (url: string) => Promise<string>
|
||||
constructor(config: EndpointConfig, probe: (url: string) => Promise<string>) {
|
||||
this.config = config; this.probe = probe; this.current = config.remote
|
||||
}
|
||||
async select(type: string): Promise<string> {
|
||||
if (type !== this.type) { this.epoch++; this.type = type; this.retryAt = 0; this.pending = null; this.current = this.config.remote }
|
||||
if (type === 'none') throw new Error('当前无网络,请连接后重试')
|
||||
if (type !== 'wifi' || !this.config.local || Date.now() < this.retryAt) return this.current
|
||||
if (this.pending) return this.pending
|
||||
let epoch = this.epoch
|
||||
let task = (async () => {
|
||||
let target = this.config.remote
|
||||
try { if (await this.probe(this.config.local) === this.config.storeId) target = this.config.local } catch { /* 探测失败回退公网 */ }
|
||||
if (epoch === this.epoch) { this.current = target; this.retryAt = Date.now() + 30_000 }
|
||||
return this.current
|
||||
})()
|
||||
this.pending = task
|
||||
try { return await task } finally { if (this.pending === task) this.pending = null }
|
||||
}
|
||||
fallback() { this.epoch++; this.pending = null; this.current = this.config.remote; this.retryAt = Date.now() + 30_000 }
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { Color } from './types.ts'
|
||||
export function quantize(pixels: Uint8ClampedArray | number[], colors: Color[]): (string|null)[] {
|
||||
let palette = colors.filter(c => c.available).map(c => ({ ...c, rgb: [1,3,5].map(i => parseInt(c.hex.slice(i,i+2),16)) }))
|
||||
if (!palette.length) throw new Error('色板至少需要一种可用颜色')
|
||||
let result: (string|null)[] = []
|
||||
for (let i=0; i<pixels.length; i+=4) {
|
||||
if (pixels[i+3] < 128) { result.push(null); continue }
|
||||
let nearest = palette[0]; let min = Infinity
|
||||
for (let color of palette) {
|
||||
let distance = color.rgb.reduce((sum,v,j) => sum + [0.299,0.587,0.114][j] * (v-pixels[i+j])**2,0)
|
||||
if (distance < min) { min = distance; nearest = color }
|
||||
}
|
||||
result.push(nearest.code)
|
||||
}
|
||||
return result
|
||||
}
|
||||
export function countBeads(cells: (string|null)[]): Record<string,number> {
|
||||
let counts: Record<string,number> = {}
|
||||
for (let cell of cells) if (cell !== null) counts[cell] = (counts[cell] || 0) + 1
|
||||
return counts
|
||||
}
|
||||
export function validateGrid(width: number, height: number, cells: (string|null)[], colors: Color[]) {
|
||||
if (![width,height].every(n => Number.isInteger(n) && n>0 && n<=128) || cells.length !== width*height) throw new Error('网格尺寸不正确,最大 128×128')
|
||||
let codes = new Set(colors.map(c => c.code))
|
||||
if (cells.some(c => c !== null && !codes.has(c))) throw new Error('图纸包含未知色号')
|
||||
if (cells.every(c => c === null)) throw new Error('空图纸不能保存')
|
||||
}
|
||||
export function cropRect(w: number,h: number,ratio: number) {
|
||||
let width = Math.min(w,h*ratio); let height = width/ratio
|
||||
return { x:(w-width)/2,y:(h-height)/2,width,height }
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
export class BusinessError extends Error { code:number; constructor(message:string,code:number){super(message);this.code=code} }
|
||||
export async function requestWithFallback<T>(base:string,local:string,remote:string,path:string,method:string,data:unknown,send:(base:string,path:string,method:string,data?:unknown)=>Promise<T>,fallback:()=>void):Promise<T>{
|
||||
try{return await send(base,path,method,data)}catch(e){
|
||||
if(e instanceof BusinessError||base!==local)throw e
|
||||
fallback()
|
||||
let retryable=method==='GET'||(path==='/service-requests'&&method==='POST'&&!!(data as {requestId?:string})?.requestId)
|
||||
if(!retryable)throw new Error('连接已切换,请先确认记录再重试,避免重复提交')
|
||||
return send(remote,path,method,data)
|
||||
}
|
||||
}
|
||||
export async function submitAndRefresh<T>(post:()=>Promise<T>,accept:(task:T)=>void,refresh:()=>Promise<unknown>):Promise<{task:T;refreshError:string}>{
|
||||
let task=await post();accept(task)
|
||||
try{await refresh();return {task,refreshError:''}}catch(e){return {task,refreshError:e instanceof Error?e.message:'暂时无法刷新记录'}}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export interface Color { code: string; name: string; hex: string; location: string; available: boolean }
|
||||
export interface Pattern { id: string; title: string; category: string; width: number; height: number; cells: (string|null)[]; colors: Color[]; beadCount: number; visibility: 'public'|'private'; ownerId?: string; createdAt?: string }
|
||||
export interface Store { id: string; name: string; address: string; hours: string; notice: string; localBaseUrl: string; publicBaseUrl: string }
|
||||
export interface User { id: string; name: string; role: string; storeId: string }
|
||||
export interface Visit { id: string; storeId: string; tableCode: string; status: string }
|
||||
export interface ServiceRequest { id: string; storeId: string; userId: string; tableCode: string; kind: 'help'|'refill'|'ironing'|'print'; note: string; status: 'pending'|'accepted'|'completed'|'cancelled'; patternId?: string; createdAt: string }
|
||||
@@ -0,0 +1,4 @@
|
||||
import { createSSRApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import App from './App.vue'
|
||||
export function createApp() { let app = createSSRApp(App); app.use(createPinia()); return { app } }
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"name": "豆间拼豆工坊",
|
||||
"appid": "",
|
||||
"description": "图纸制作与门店服务",
|
||||
"versionName": "0.1.0",
|
||||
"versionCode": 100,
|
||||
"vueVersion": "3",
|
||||
"mp-weixin": {
|
||||
"appid": "",
|
||||
"setting": {
|
||||
"urlCheck": true
|
||||
},
|
||||
"usingComponents": true
|
||||
},
|
||||
"h5": {
|
||||
"title": "豆间 · 拼豆工坊",
|
||||
"router": {
|
||||
"mode": "hash"
|
||||
}
|
||||
},
|
||||
"uniStatistics": {
|
||||
"enable": false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<script setup lang="ts">
|
||||
import {ref} from 'vue'
|
||||
import {onLoad,onShow} from '@dcloudio/uni-app'
|
||||
import {api,showError} from '../../services/api'
|
||||
import {useSession} from '../../stores/session'
|
||||
import {goPage,goMain} from '../../core/navigation'
|
||||
import type {Pattern} from '../../core/types'
|
||||
import PatternCanvas from '../../components/PatternCanvas.vue'
|
||||
import PageReturn from '../../components/ui/PageReturn.vue'
|
||||
let session=useSession(),patterns=ref<Pattern[]>([]),kind=ref('works'),busy=ref(false),error=ref(''),missing=ref(0)
|
||||
onLoad(options=>{kind.value=options?.type==='favorites'?'favorites':'works'})
|
||||
async function load(){if(busy.value)return;error.value='';missing.value=0;if(!uni.getStorageSync('access-token')){patterns.value=[];return}busy.value=true;try{await session.ensure();if(kind.value==='works')patterns.value=await api<Pattern[]>('/my/patterns');else{let ids=await api<string[]>('/favorites');let results=await Promise.allSettled(ids.map(id=>api<Pattern>('/patterns/'+encodeURIComponent(id))));patterns.value=results.flatMap(r=>r.status==='fulfilled'?[r.value]:[]);missing.value=results.filter(r=>r.status==='rejected').length}}catch(e){error.value=(e as Error).message}finally{busy.value=false}}
|
||||
onShow(load)
|
||||
async function login(){try{await session.login();await load()}catch(e){showError(e)}}
|
||||
function remove(p:Pattern){uni.showModal({title:'删除作品',content:'删除账号中的「'+p.title+'」?此操作无法撤销。',success:async r=>{if(!r.confirm)return;try{await api('/patterns/'+encodeURIComponent(p.id),'DELETE');await load()}catch(e){showError(e)}}})}
|
||||
</script>
|
||||
<template><view class="page"><PageReturn tab="mine"/><view class="page-heading"><view class="eyebrow">YOUR LITTLE COLLECTION</view><view class="title">{{kind==='works'?'我的作品':'我的收藏'}}</view><view class="muted">{{kind==='works'?'每一次创作,都值得留下。':'把心动留到下一次。'}}</view></view>
|
||||
<view v-if="error" class="error">{{error}}<button @tap="load">重新加载</button></view><view v-else-if="busy" class="empty">正在整理图纸…</view><view v-else-if="!session.user" class="empty"><view class="empty-title">登录后查看图纸</view><button class="primary mt" @tap="login">微信登录</button></view><template v-else><view v-if="missing" class="page-note">有 {{missing}} 张收藏暂时无法加载。<button @tap="load">重新加载</button></view><view v-if="!patterns.length" class="empty"><view class="empty-title">这里还没有图纸</view><button class="mt" @tap="goMain(kind==='works'?'make':'gallery')">{{kind==='works'?'开始创作':'去挑一张图纸'}}</button></view><view v-else class="library"><view v-for="p in patterns" :key="p.id" class="library-item"><view class="preview"><PatternCanvas :pattern="p" :size="80"/></view><view class="grow"><view class="art-title">{{p.title}}</view><view class="muted">{{p.width}} × {{p.height}} 格 · {{p.beadCount}} 颗</view><button class="open-button" @tap="goPage('packages/patterns/detail',{id:p.id})">继续拼豆 →</button></view><button v-if="kind==='works'" class="delete-button danger" :aria-label="'删除'+p.title" @tap="remove(p)">删除</button></view></view></template>
|
||||
</view></template><style scoped>.library{display:flex;flex-direction:column;gap:12px}.library-item{display:flex;gap:14px;align-items:center;padding:16px 12px;background:#fffdf7;border:1px solid #e1e3d8;border-radius:12px}.preview{flex-shrink:0}.open-button{background:transparent;text-align:left;padding:0;min-height:44px;font-size:12px}.open-button::after{border:0}.delete-button{font-size:12px;padding:0 8px;min-height:44px;flex-shrink:0}</style>
|
||||
@@ -0,0 +1,26 @@
|
||||
<script setup lang="ts">
|
||||
import {ref,getCurrentInstance,nextTick,computed} from 'vue'
|
||||
import {onLoad} from '@dcloudio/uni-app'
|
||||
import SettingRow from '../../components/ui/SettingRow.vue'
|
||||
import EditDialog from '../../components/ui/EditDialog.vue'
|
||||
import ChoiceSheet from '../../components/ui/ChoiceSheet.vue'
|
||||
import PageReturn from '../../components/ui/PageReturn.vue'
|
||||
import {useCreation} from '../../stores/creation'
|
||||
import {api,storeId,showError} from '../../services/api'
|
||||
import {goPage} from '../../core/navigation'
|
||||
import {creationSize} from '../../core/creation'
|
||||
import {cropRect,quantize} from '../../core/pattern'
|
||||
import type {Color} from '../../core/types'
|
||||
let creation=useCreation(),instance=getCurrentInstance(),busy=ref(false),error=ref(''),field=ref(''),choice=ref('')
|
||||
let labels:Record<string,string>={title:'作品名称',width:'画布宽度 / 格',height:'画布高度 / 格',text:'输入文字'}
|
||||
let editValue=computed(()=>field.value==='title'?creation.title:field.value==='text'?creation.text:field.value==='width'?String(creation.width):String(creation.height))
|
||||
let options=computed(()=>creation.colors.filter(c=>c.available).map(c=>({value:c.code,label:c.code+' · '+c.name,color:c.hex,description:c.location})))
|
||||
async function load(){try{creation.colors=await api<Color[]>('/palettes?storeId='+storeId);if(!creation.colors.some(c=>c.code===creation.paint&&c.available))creation.paint=creation.colors.find(c=>c.available)?.code||null;error.value=''}catch(e){error.value=(e as Error).message}}
|
||||
onLoad(query=>{creation.start(String(query?.mode||'image'));void load()})
|
||||
function confirm(value:string){try{if(field.value==='width')creation.width=creationSize(value);else if(field.value==='height')creation.height=creationSize(value);else if(field.value==='title'){if(!value.trim())throw new Error('请输入作品名称');creation.title=value.trim()}else creation.text=value;field.value=''}catch(e){showError(e)}}
|
||||
function select(values:string[]){if(choice.value==='colors')creation.excluded=creation.colors.filter(c=>!values.includes(c.code)).map(c=>c.code);else creation.paint=values[0]||null;choice.value=''}
|
||||
async function choose(){try{let result=await new Promise<UniApp.ChooseImageSuccessCallbackResult>((resolve,reject)=>uni.chooseImage({count:1,sizeType:['compressed'],success:resolve,fail:reject}));let path=result.tempFilePaths[0];let info=await new Promise<UniApp.GetImageInfoSuccessData>((resolve,reject)=>uni.getImageInfo({src:path,success:resolve,fail:reject}));creation.imagePath=path;creation.imageInfo={width:info.width,height:info.height}}catch(e){if(!(e as {errMsg?:string})?.errMsg?.includes('cancel'))showError(e)}}
|
||||
async function generate(){busy.value=true;error.value='';try{let w=creationSize(creation.width),h=creationSize(creation.height),colors=creation.activeColors;if(!colors.length)throw new Error('请至少选择一种可用颜色');await nextTick();let ctx=uni.createCanvasContext('source',instance);ctx.clearRect(0,0,w,h);if(creation.mode==='image'){if(!creation.imagePath)throw new Error('请先选择图片');let r=cropRect(creation.imageInfo.width,creation.imageInfo.height,w/h);ctx.drawImage(creation.imagePath,r.x,r.y,r.width,r.height,0,0,w,h)}else if(creation.mode==='text'){if(!creation.text.trim())throw new Error('请输入文字');ctx.setFillStyle(colors.find(c=>c.code===creation.paint)?.hex||colors[0].hex);ctx.setFontSize(Math.min(h*.75,w/(creation.text.length+.3)));ctx.setTextAlign('center');ctx.setTextBaseline('middle');ctx.fillText(creation.text,w/2,h/2)}await new Promise<void>(resolve=>ctx.draw(false,()=>resolve()));let cells:(string|null)[]=creation.mode==='blank'?Array(w*h).fill(null):quantize((await new Promise<UniApp.CanvasGetImageDataRes>((resolve,reject)=>uni.canvasGetImageData({canvasId:'source',x:0,y:0,width:w,height:h,success:resolve,fail:reject},instance))).data,colors);creation.pattern={id:'draft',title:creation.title,category:'我的创作',width:w,height:h,cells,colors:colors.map(c=>({...c})),beadCount:cells.filter(c=>c!==null).length,visibility:'private'};creation.persist();goPage('packages/creation/editor')}catch(e){error.value=(e as Error).message}finally{busy.value=false}}
|
||||
</script>
|
||||
<template><view class="page"><PageReturn tab="make"/><view class="page-heading"><view class="eyebrow">SET UP YOUR CREATION</view><view class="title">{{creation.mode==='image'?'图片转图纸':creation.mode==='text'?'文字转图纸':'新建手绘画布'}}</view><view class="muted">点开设置,调整好后再进入画布。</view></view><view class="settings-group mt"><SettingRow label="作品名称" :value="creation.title" @activate="field='title'"/><SettingRow label="画布宽度" :value="creation.width+' 格'" @activate="field='width'"/><SettingRow label="画布高度" :value="creation.height+' 格'" @activate="field='height'"/></view><view class="settings-group mt"><SettingRow v-if="creation.mode==='image'" label="创作图片" :value="creation.imagePath?'已选择 · 更换':'从相册选择'" description="按画布比例居中裁剪;选择后不会自动生成" @activate="choose"/><SettingRow v-if="creation.mode==='text'" label="文字内容" :value="creation.text||'尚未输入'" @activate="field='text'"/><SettingRow label="使用色板" :value="creation.activeColors.length+' 种颜色'" description="仅使用当前门店可用色号" @activate="choice='colors'"/><SettingRow v-if="creation.mode==='text'" label="文字颜色" :value="creation.paint||'默认色'" @activate="choice='paint'"/></view><view v-if="error" class="error mt">{{error}}<button v-if="!creation.colors.length" @tap="load">重新加载色板</button></view><view class="hint mt">宽高为 8–96 格。图片仅在本机处理,文字效果受设备字体影响。</view><view class="action-footer mt"><button class="primary" :loading="busy" :disabled="busy||!creation.colors.length" @tap="generate">下一步 · {{creation.mode==='blank'?'进入画布':'生成并微调'}}</button></view><EditDialog :open="!!field" :title="labels[field]||''" :value="editValue" :type="field==='width'||field==='height'?'number':'text'" :maxlength="field==='text'?8:60" @close="field=''" @confirm="confirm"/><ChoiceSheet :open="!!choice" :title="choice==='colors'?'选择使用颜色':'选择文字颜色'" :options="choice==='paint'?options.filter(c=>creation.activeColors.some(v=>v.code===c.value)):options" :selected="choice==='colors'?creation.activeColors.map(c=>c.code):creation.paint?[creation.paint]:[]" :multiple="choice==='colors'" @close="choice=''" @confirm="select"/><canvas canvas-id="source" id="source" class="source-canvas" :style="{width:creation.width+'px',height:creation.height+'px'}"/></view></template>
|
||||
<style scoped>.source-canvas{position:absolute;left:-2000px;top:0}</style>
|
||||
@@ -0,0 +1,22 @@
|
||||
<script setup lang="ts">
|
||||
import {computed,ref} from 'vue'
|
||||
import {onLoad} from '@dcloudio/uni-app'
|
||||
import PatternCanvas from '../../components/PatternCanvas.vue'
|
||||
import SettingRow from '../../components/ui/SettingRow.vue'
|
||||
import ChoiceSheet from '../../components/ui/ChoiceSheet.vue'
|
||||
import PageReturn from '../../components/ui/PageReturn.vue'
|
||||
import {useCreation} from '../../stores/creation'
|
||||
import {useSession} from '../../stores/session'
|
||||
import {api,storeId,showError} from '../../services/api'
|
||||
import {goPage} from '../../core/navigation'
|
||||
import {validateGrid,countBeads} from '../../core/pattern'
|
||||
import type {Pattern,Color} from '../../core/types'
|
||||
let creation=useCreation(),session=useSession(),busy=ref(false),sheet=ref(false),error=ref('')
|
||||
let size=computed(()=>Math.min(320,uni.getSystemInfoSync().windowWidth-40)),paintLabel=computed(()=>creation.paint?creation.colors.find(c=>c.code===creation.paint)?.name+' · '+creation.paint:'橡皮 · 透明格'),options=computed(()=>[{value:'__erase',label:'橡皮',description:'透明格不计豆数'},...creation.colors.filter(c=>c.available).map(c=>({value:c.code,label:c.code+' · '+c.name,color:c.hex,description:c.location}))]),count=computed(()=>creation.pattern?Object.keys(countBeads(creation.pattern.cells)).length:0)
|
||||
onLoad(async()=>{try{if(!creation.pattern)creation.restore();creation.colors=await api<Color[]>('/palettes?storeId='+storeId)}catch(e){error.value=(e as Error).message}})
|
||||
function select(values:string[]){creation.paint=values[0]==='__erase'?null:values[0]||null;sheet.value=false}
|
||||
async function save(){let p=creation.pattern;if(!p)return;busy.value=true;try{validateGrid(p.width,p.height,p.cells,p.colors);await session.ensure();let saved=await api<Pattern>('/patterns','POST',{...p,storeId});creation.persist();goPage('packages/patterns/detail',{id:saved.id})}catch(e){showError(e)}finally{busy.value=false}}
|
||||
function restore(){try{creation.restore();error.value=''}catch(e){showError(e)}}
|
||||
</script>
|
||||
<template><view class="page"><PageReturn tab="make"/><view class="page-heading"><view class="eyebrow">ONE BEAD AT A TIME</view><view class="title">{{creation.pattern?.title||'作品微调'}}</view><view class="muted">选择颜色,再点格子修改。每次修改自动存入本地草稿。</view></view><view v-if="error" class="error mt">{{error}}</view><template v-if="creation.pattern"><view class="row spread mt"><text class="section-title">{{creation.pattern.width}} × {{creation.pattern.height}} 格</text><text class="muted">{{creation.pattern.beadCount}} 颗 · {{count}} 色</text></view><view class="settings-group mt"><SettingRow label="当前画笔" :value="paintLabel" description="点开选择颜色或橡皮" @activate="sheet=true"/></view><view class="canvas-area mt"><PatternCanvas :pattern="creation.pattern" :size="size" grid @cell="creation.edit"/></view><view class="hint mt">透明格不计用豆量。清除应用数据会移除本地草稿;保存到账号后可在我的作品中查看。</view><view class="action-footer mt"><button class="primary" :loading="busy" :disabled="busy" @tap="save">保存到我的作品</button></view></template><view v-else class="empty mt">没有可编辑的画布,请返回制作页开始新创作。<button @tap="restore">尝试恢复草稿</button></view><ChoiceSheet :open="sheet" title="选择画笔" :options="options" :selected="[creation.paint||'__erase']" @close="sheet=false" @confirm="select"/></view></template>
|
||||
<style scoped>.canvas-area{display:flex;justify-content:center;overflow:auto;padding:8px 0}</style>
|
||||
@@ -0,0 +1,28 @@
|
||||
<script setup lang="ts">
|
||||
import {computed,ref} from 'vue'
|
||||
import {onLoad} from '@dcloudio/uni-app'
|
||||
import {api,showError} from '../../services/api'
|
||||
import {countBeads} from '../../core/pattern'
|
||||
import {goPage} from '../../core/navigation'
|
||||
import type {Pattern} from '../../core/types'
|
||||
import PatternCanvas from '../../components/PatternCanvas.vue'
|
||||
import SettingRow from '../../components/ui/SettingRow.vue'
|
||||
import ChoiceSheet from '../../components/ui/ChoiceSheet.vue'
|
||||
import PageReturn from '../../components/ui/PageReturn.vue'
|
||||
import {useSession} from '../../stores/session'
|
||||
let session=useSession(),pattern=ref<Pattern|null>(null),error=ref(''),id=ref(''),highlight=ref(''),zoom=ref(1),done=ref<number[]>([]),favorite=ref(false),saving=ref(false),position=ref(''),sheet=ref('')
|
||||
let size=computed(()=>pattern.value?Math.max(280,pattern.value.width*12)*zoom.value:280),counts=computed(()=>pattern.value?countBeads(pattern.value.cells):{})
|
||||
let options=computed(()=>sheet.value==='zoom'?[1,2,3].map(n=>({value:String(n),label:n+' 倍'})):[{value:'',label:'显示全部颜色'},...(pattern.value?.colors.filter(c=>counts.value[c.code]).map(c=>({value:c.code,label:c.code+' · '+c.name,color:c.hex,description:counts.value[c.code]+' 颗'}))||[])])
|
||||
async function load(){error.value='';if(!id.value){error.value='缺少图纸编号,请返回图纸首页。';return}try{pattern.value=await api<Pattern>('/patterns/'+encodeURIComponent(id.value));let saved=uni.getStorageSync('progress-'+id.value);done.value=Array.isArray(saved)?[...new Set(saved.filter((i:unknown)=>typeof i==='number'&&Number.isInteger(i)&&i>=0&&!!pattern.value?.cells[i]))] as number[]:[];if(uni.getStorageSync('access-token'))try{favorite.value=(await api<string[]>('/favorites')).includes(id.value)}catch{/* Favorite state must not block the canvas. */}}catch(e){error.value=(e as Error).message}}
|
||||
onLoad(options=>{id.value=options?.id||'';load()})
|
||||
function mark(i:number){if(!pattern.value)return;position.value=`第 ${Math.floor(i/pattern.value.width)+1} 行,第 ${i%pattern.value.width+1} 列`;if(!pattern.value.cells[i])return;done.value=done.value.includes(i)?done.value.filter(n=>n!==i):[...done.value,i];uni.setStorageSync('progress-'+pattern.value.id,done.value)}
|
||||
async function star(){if(!pattern.value||saving.value)return;saving.value=true;try{await session.ensure();await api('/favorites/'+encodeURIComponent(id.value),'PUT',{active:!favorite.value});favorite.value=!favorite.value}catch(e){showError(e)}finally{saving.value=false}}
|
||||
function select(values:string[]){if(sheet.value==='zoom')zoom.value=Number(values[0]);else highlight.value=values[0];sheet.value=''}
|
||||
</script>
|
||||
<template><view class="page"><PageReturn tab="gallery"/><view v-if="error" class="error">{{error}}<button v-if="id" @tap="load">重新加载</button></view><view v-else-if="!pattern" class="empty">正在加载图纸…</view><template v-else><view class="page-heading"><view class="eyebrow">ONE BEAD AT A TIME</view><view class="title">{{pattern.title}}</view><view class="muted">{{pattern.width}} × {{pattern.height}} 格 · {{pattern.beadCount}} 颗</view></view>
|
||||
<view class="row spread"><text>跟做进度</text><text class="muted">{{done.length}} / {{pattern.beadCount}} 颗</text></view><view class="progress-track"><view :style="{width:(pattern.beadCount?done.length/pattern.beadCount*100:0)+'%'}"/></view>
|
||||
<scroll-view scroll-x scroll-y class="board"><view :style="{width:size+'px'}"><PatternCanvas :pattern="pattern" :size="size" :highlight="highlight" :completed="done" grid @cell="mark"/></view></scroll-view><view class="page-note">{{position||'拖动画布查看,点击豆子标记完成。'}}</view>
|
||||
<view class="settings-group"><SettingRow label="查看比例" :value="zoom+' 倍'" @activate="sheet='zoom'"/><SettingRow label="高亮颜色" :value="highlight||'全部颜色'" @activate="sheet='color'"/><SettingRow label="收藏图纸" :value="saving?'正在保存…':favorite?'已收藏':'未收藏'" :disabled="saving" @activate="star"/></view>
|
||||
<view class="section-title">制作准备</view><view class="settings-group"><SettingRow label="用料清单" :value="Object.keys(counts).length+' 种颜色'" description="查看用豆数量与料盒位置" @activate="goPage('packages/patterns/materials',{id})"/><SettingRow label="打印图纸" description="设置打印尺寸或请店员帮忙" @activate="goPage('packages/patterns/print',{id})"/></view>
|
||||
<ChoiceSheet :open="!!sheet" :title="sheet==='zoom'?'查看比例':'高亮颜色'" :options="options" :selected="[sheet==='zoom'?String(zoom):highlight]" @close="sheet=''" @confirm="select"/></template></view></template>
|
||||
<style scoped>.board{height:350px;background:#e7eadf;border:1px solid #d1d9ca;border-radius:10px}.progress-track{height:5px;background:#e1e6d8;border-radius:4px;overflow:hidden;margin:12px 0 20px}.progress-track>view{height:100%;background:#597b49}.board+.page-note{margin:8px 2px 20px}</style>
|
||||
@@ -0,0 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import {computed,ref} from 'vue'
|
||||
import {onLoad} from '@dcloudio/uni-app'
|
||||
import {api} from '../../services/api'
|
||||
import {countBeads} from '../../core/pattern'
|
||||
import type {Pattern} from '../../core/types'
|
||||
import PageReturn from '../../components/ui/PageReturn.vue'
|
||||
let pattern=ref<Pattern|null>(null),error=ref(''),id=ref(''),counts=computed(()=>pattern.value?countBeads(pattern.value.cells):{})
|
||||
async function load(){error.value='';if(!id.value){error.value='缺少图纸编号,请返回图纸首页。';return}try{pattern.value=await api<Pattern>('/patterns/'+encodeURIComponent(id.value))}catch(e){error.value=(e as Error).message}}
|
||||
onLoad(options=>{id.value=options?.id||'';load()})
|
||||
</script>
|
||||
<template><view class="page"><PageReturn tab="gallery"/><view class="page-heading"><view class="eyebrow">READY TO MAKE</view><view class="title">用料清单</view><view class="muted">{{pattern?.title||'按色号准备,拼起来更从容。'}}</view></view><view v-if="error" class="error">{{error}}<button v-if="id" @tap="load">重新加载</button></view><view v-else-if="!pattern" class="empty">正在计算用料…</view><template v-else><view class="row spread"><text>{{Object.keys(counts).length}} 种颜色</text><text class="muted">共 {{pattern.beadCount}} 颗</text></view><view class="settings-group"><view v-for="c in pattern.colors.filter(c=>counts[c.code])" :key="c.code" class="material"><text class="swatch" :style="{background:c.hex}"/><view class="grow"><view>{{c.code}} · {{c.name}}</view><view class="muted">料盒:{{c.location||'请询问店员'}}{{c.available?'':' · 暂不可用'}}</view></view><text class="quantity">{{counts[c.code]}}<text class="muted"> 颗</text></text></view></view><view class="hint">请核对门店实际豆子品牌与色号。熨烫前可请店员检查作品。</view></template></view></template><style scoped>.material{display:flex;align-items:center;gap:14px;padding:18px 16px;border-bottom:1px solid #e1e3d8}.material:last-child{border:0}.quantity{font-weight:600;white-space:nowrap}.material .swatch{flex-shrink:0;width:24px;height:24px}</style>
|
||||
@@ -0,0 +1,22 @@
|
||||
<script setup lang="ts">
|
||||
import {ref} from 'vue'
|
||||
import {onLoad} from '@dcloudio/uni-app'
|
||||
import {api,showError,downloadPrint} from '../../services/api'
|
||||
import {goPage} from '../../core/navigation'
|
||||
import {useSession} from '../../stores/session'
|
||||
import type {Pattern} from '../../core/types'
|
||||
import PageReturn from '../../components/ui/PageReturn.vue'
|
||||
import SettingRow from '../../components/ui/SettingRow.vue'
|
||||
import EditDialog from '../../components/ui/EditDialog.vue'
|
||||
import ChoiceSheet from '../../components/ui/ChoiceSheet.vue'
|
||||
let pattern=ref<Pattern|null>(null),id=ref(''),error=ref(''),pitch=ref(5),method=ref('store'),sheet=ref(''),busy=ref(false),session=useSession()
|
||||
let methods=[{value:'store',label:'到店申请打印',description:'交给店员确认并打印'}]
|
||||
// #ifdef H5
|
||||
methods.push({value:'download',label:'下载打印文件',description:'下载 SVG 文件,以 100% 比例打印'})
|
||||
// #endif
|
||||
async function load(){error.value='';if(!id.value){error.value='缺少图纸编号,请返回图纸首页。';return}try{pattern.value=await api<Pattern>('/patterns/'+encodeURIComponent(id.value))}catch(e){error.value=(e as Error).message}}
|
||||
onLoad(options=>{id.value=options?.id||'';load()})
|
||||
function setPitch(value:string){let n=Number(value);if(!Number.isFinite(n)||n<2||n>10){showError(new Error('针距应在 2–10 mm 之间'));return}pitch.value=n;sheet.value=''}
|
||||
async function submit(){if(!pattern.value||busy.value)return;if(method.value==='store'){goPage('packages/store/request',{kind:'print',patternId:id.value});return}busy.value=true;try{await session.ensure();await downloadPrint(id.value,pitch.value)}catch(e){showError(e)}finally{busy.value=false}}
|
||||
</script>
|
||||
<template><view class="page"><PageReturn tab="gallery"/><view class="page-heading"><view class="eyebrow">FROM SCREEN TO PEGBOARD</view><view class="title">打印图纸</view><view class="muted">{{pattern?.title||'准备一份放在手边的图纸。'}}</view></view><view v-if="error" class="error">{{error}}<button v-if="id" @tap="load">重新加载</button></view><view v-else-if="!pattern" class="empty">正在加载图纸…</view><template v-else><view class="settings-group"><SettingRow label="打印方式" :value="method==='store'?'到店打印':'下载文件'" @activate="sheet='method'"/><SettingRow v-if="method==='download'" label="豆板针距" :value="pitch+' mm'" description="按实际豆板设置" @activate="sheet='pitch'"/></view><view class="panel"><view>{{pattern.width}} × {{pattern.height}} 格 · {{pattern.beadCount}} 颗</view><view v-if="method==='download'" class="muted mt">图案尺寸约 {{(pattern.width*pitch).toFixed(1)}} × {{(pattern.height*pitch).toFixed(1)}} mm。请以 100% 比例打印,并检查校准标尺。</view><view v-else class="muted mt">下一步确认桌位并提交申请。店员会根据实际豆板确认针距和打印尺寸。</view></view><view class="action-footer"><button class="primary" :loading="busy" :disabled="busy" @tap="submit">{{method==='store'?'下一步 · 申请打印':'下载打印文件'}}</button></view></template><EditDialog :open="sheet==='pitch'" title="豆板针距 / mm" type="digit" :value="pitch" @close="sheet=''" @confirm="setPitch"/><ChoiceSheet :open="sheet==='method'" title="打印方式" :options="methods" :selected="[method]" @close="sheet=''" @confirm="values=>{method=values[0];sheet=''}"/></view></template>
|
||||
@@ -0,0 +1,70 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { onShow, onHide, onUnload } from '@dcloudio/uni-app'
|
||||
import { api, showError } from '../../services/api'
|
||||
import { submitAndRefresh } from '../../core/transport'
|
||||
import { goPage } from '../../core/navigation'
|
||||
import { useSession } from '../../stores/session'
|
||||
import { useVisit, serviceNames, serviceStatuses } from '../../stores/visit'
|
||||
import PageReturn from '../../components/ui/PageReturn.vue'
|
||||
import SettingRow from '../../components/ui/SettingRow.vue'
|
||||
import type { ServiceRequest } from '../../core/types'
|
||||
let flow = useVisit(), session = useSession(), loading = ref(false), error = ref(''), cancelling = ref(''), loggedIn = ref(false), visible = false, epoch = 0
|
||||
let timer: ReturnType<typeof setTimeout> | undefined
|
||||
let tasks = computed(() => [...flow.tasks].sort((a, b) => Date.parse(b.createdAt) - Date.parse(a.createdAt)))
|
||||
let active = computed(() => tasks.value.filter(task => task.status === 'pending' || task.status === 'accepted').length)
|
||||
function stop() { visible = false; ++epoch; if (timer) clearTimeout(timer); timer = undefined }
|
||||
async function load() {
|
||||
if (!visible || loading.value) return
|
||||
let generation = epoch
|
||||
loading.value = true
|
||||
try { await flow.refreshTasks(); if (generation === epoch) error.value = '' }
|
||||
catch (e) { if (generation === epoch) error.value = e instanceof Error ? e.message : '记录暂不可用' }
|
||||
finally {
|
||||
if (generation === epoch) { loading.value = false; loggedIn.value = !!uni.getStorageSync('access-token') }
|
||||
if (visible && loggedIn.value && generation === epoch) { if (timer) clearTimeout(timer); timer = setTimeout(load, 10000) }
|
||||
}
|
||||
}
|
||||
onShow(() => { stop(); visible = true; loading.value = false; load() })
|
||||
onHide(stop); onUnload(stop)
|
||||
async function login() {
|
||||
try { await session.ensure(); await load() }
|
||||
catch (e) { showError(e) }
|
||||
}
|
||||
async function cancel(task: ServiceRequest) {
|
||||
if (cancelling.value || task.status !== 'pending') return
|
||||
cancelling.value = task.id
|
||||
try {
|
||||
let confirmed = await new Promise<boolean>(resolve => uni.showModal({ title: '取消这项服务?', content: serviceNames[task.kind] + ' · ' + task.tableCode + ' 桌', confirmText: '确认取消', success: result => resolve(result.confirm), fail: () => resolve(false) }))
|
||||
if (!confirmed) return
|
||||
let result = await submitAndRefresh(
|
||||
() => api<ServiceRequest>('/service-requests/' + encodeURIComponent(task.id), 'PATCH', { status: 'cancelled' }),
|
||||
changed => { flow.tasks = flow.tasks.map(item => item.id === task.id ? { ...item, ...changed, status: 'cancelled' } : item) },
|
||||
flow.refreshTasks
|
||||
)
|
||||
error.value = result.refreshError ? '服务已取消,记录刷新暂不可用:' + result.refreshError : ''
|
||||
uni.showToast({ title: '已取消这项服务', icon: 'none' })
|
||||
} catch (e) { showError(e) }
|
||||
finally { cancelling.value = '' }
|
||||
}
|
||||
function when(value: string) { let date = new Date(value); return Number.isNaN(date.getTime()) ? '时间暂不可用' : date.toLocaleString() }
|
||||
</script>
|
||||
<template>
|
||||
<view class="page">
|
||||
<PageReturn tab="store" />
|
||||
<view class="page-heading"><view class="eyebrow">AT YOUR TABLE</view><view class="title">服务记录</view><view class="muted">{{ active }} 项进行中 · 页面显示时自动更新</view></view>
|
||||
<view v-if="error" class="error" role="alert">{{ error }}<button class="mt" :disabled="loading" @tap="load">刷新记录</button></view>
|
||||
<view v-if="loading && !tasks.length" class="empty" role="status">正在获取服务记录…</view>
|
||||
<view v-else-if="!tasks.length" class="empty"><view>还没有服务记录</view><view class="muted mt">确认桌位后,可以提交需要的帮助。</view><button v-if="!loggedIn" class="mt" @tap="login">登录并查看记录</button></view>
|
||||
<view v-for="task in tasks" :key="task.id" class="panel">
|
||||
<view class="row spread wrap"><text class="request-name">{{ serviceNames[task.kind] }} · {{ task.tableCode }} 桌</text><text class="chip">{{ serviceStatuses[task.status] }}</text></view>
|
||||
<view class="muted mt note">{{ task.note || '无补充说明' }}</view><view class="muted">{{ when(task.createdAt) }}</view>
|
||||
<view v-if="task.patternId" class="muted note">已关联打印图纸</view>
|
||||
<button v-if="task.status === 'pending'" class="mt" :disabled="!!cancelling" :loading="cancelling === task.id" @tap="cancel(task)">取消这项服务</button>
|
||||
</view>
|
||||
<view class="settings-group mt"><SettingRow label="申请新的服务" value="选择需要的帮助" @activate="goPage('packages/store/request')" /></view>
|
||||
</view>
|
||||
</template>
|
||||
<style scoped>
|
||||
.request-name{font-weight:600;overflow-wrap:anywhere}.note{white-space:pre-wrap;overflow-wrap:anywhere}
|
||||
</style>
|
||||
@@ -0,0 +1,79 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { onLoad, onShow } from '@dcloudio/uni-app'
|
||||
import { useVisit, serviceNames } from '../../stores/visit'
|
||||
import { showError } from '../../services/api'
|
||||
import { goMain, goPage } from '../../core/navigation'
|
||||
import PageReturn from '../../components/ui/PageReturn.vue'
|
||||
import SettingRow from '../../components/ui/SettingRow.vue'
|
||||
import ChoiceSheet from '../../components/ui/ChoiceSheet.vue'
|
||||
import EditDialog from '../../components/ui/EditDialog.vue'
|
||||
import type { ServiceRequest } from '../../core/types'
|
||||
let flow = useVisit(), choosing = ref(false), editing = ref(false), confirming = ref(false), loading = ref(false), error = ref('')
|
||||
let options = Object.entries(serviceNames).map(([value, label]) => ({ value, label, description: { help: '请员工到桌边帮忙', refill: '缺少的色号可以写在备注中', ironing: '作品完成后,请员工协助熨烫', print: '按已关联的图纸申请门店打印' }[value as ServiceRequest['kind']] }))
|
||||
let printDescription = computed(() => flow.patternId ? '已关联图纸 · ' + flow.patternId : '尚未关联图纸,请从图纸详情发起打印')
|
||||
onLoad(query => {
|
||||
let patternId = query?.patternId || ''
|
||||
if (!patternId && query?.kind === 'print') patternId = uni.getStorageSync('print-pattern') || ''
|
||||
flow.configureRequest({ kind: query?.kind, patternId })
|
||||
if (!flow.patternId && !query?.kind) {
|
||||
let legacy = uni.getStorageSync('print-pattern')
|
||||
if (typeof legacy === 'string' && legacy) flow.configureRequest({ kind: 'print', patternId: legacy })
|
||||
}
|
||||
})
|
||||
onShow(async () => {
|
||||
loading.value = true; error.value = ''
|
||||
try { await flow.refreshVisit() }
|
||||
catch (e) { error.value = e instanceof Error ? e.message : '桌位信息暂不可用' }
|
||||
finally { loading.value = false }
|
||||
})
|
||||
function choose(values: string[]) {
|
||||
let selected = values[0]
|
||||
if (selected && Object.prototype.hasOwnProperty.call(serviceNames, selected)) flow.kind = selected as ServiceRequest['kind']
|
||||
choosing.value = false
|
||||
}
|
||||
function edit(value: string) { flow.note = value; editing.value = false }
|
||||
async function submit() {
|
||||
if (flow.busy || confirming.value || flow.submitted) return
|
||||
if (!flow.visit) { goPage('packages/store/visit', { from: 'request' }); return }
|
||||
if (flow.kind === 'print' && !flow.patternId) { showError(new Error('请先从图纸详情关联打印图纸')); return }
|
||||
confirming.value = true
|
||||
try {
|
||||
let confirmed = await new Promise<boolean>(resolve => uni.showModal({ title: '确认申请服务?', content: serviceNames[flow.kind] + ' · ' + flow.visit?.tableCode + ' 桌' + (flow.note.trim() ? '\n' + flow.note.trim() : ''), confirmText: '确认提交', success: result => resolve(result.confirm), fail: () => resolve(false) }))
|
||||
if (!confirmed) return
|
||||
error.value = ''
|
||||
await flow.submit()
|
||||
uni.showToast({ title: '已提交,等待员工接单', icon: 'none' })
|
||||
} catch (e) { error.value = e instanceof Error ? e.message : '提交暂不可用'; showError(e) }
|
||||
finally { confirming.value = false }
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<view class="page">
|
||||
<PageReturn tab="store" />
|
||||
<view class="page-heading"><view class="eyebrow">A LITTLE HELP</view><view class="title">申请服务</view><view class="muted">选好需要的帮助,确认后我们会收到申请。</view></view>
|
||||
<view v-if="error" class="error" role="alert">{{ error }}</view>
|
||||
<view v-if="flow.submitted" class="panel" role="status">
|
||||
<text class="chip">已成功提交</text><view class="section-title">{{ serviceNames[flow.submitted.kind] }} · {{ flow.submitted.tableCode }} 桌</view><view class="muted">请在服务记录中查看进度,无需再次提交。</view>
|
||||
<view v-if="flow.refreshError" class="hint mt">申请已提交,记录刷新暂不可用:{{ flow.refreshError }}</view>
|
||||
<button class="primary mt" @tap="goPage('packages/store/records')">查看服务记录</button>
|
||||
<button class="mt" @tap="flow.startNewRequest">另建一条服务申请</button>
|
||||
</view>
|
||||
<template v-else>
|
||||
<view v-if="loading" class="muted" role="status">正在核对桌位…</view>
|
||||
<view v-else-if="!flow.visit" class="panel"><view class="section-title">先找到你的座位</view><view class="muted">确认桌位后即可继续当前申请,已填写的选项会保留。</view><button class="primary mt" @tap="goPage('packages/store/visit', { from: 'request' })">去确认到店</button></view>
|
||||
<view v-else class="panel"><text class="chip">当前桌位</text><view class="section-title">{{ flow.visit.tableCode }} 桌</view><view class="muted">员工会到这个桌位提供帮助。</view></view>
|
||||
<view class="settings-group">
|
||||
<SettingRow label="服务类型" :value="serviceNames[flow.kind]" description="点击选择呼叫、补豆、熨烫或打印" :disabled="flow.busy || confirming" @activate="choosing = true" />
|
||||
<SettingRow label="补充说明" :value="flow.note || '选填'" description="例如缺少的色号,或希望员工帮忙检查作品" :disabled="flow.busy || confirming" @activate="editing = true" />
|
||||
</view>
|
||||
<view v-if="flow.kind === 'print'" class="panel mt"><view class="section-title">打印图纸</view><view class="muted pattern-reference">{{ printDescription }}</view><button v-if="!flow.patternId" class="mt" @tap="goMain('gallery')">去选择图纸</button></view>
|
||||
<view class="action-footer"><button class="primary" :disabled="flow.busy || confirming || loading || !flow.visit || (flow.kind === 'print' && !flow.patternId)" :loading="flow.busy" @tap="submit">确认申请服务</button><view class="muted mt">员工接单后会更新服务记录,请勿重复提交。</view></view>
|
||||
</template>
|
||||
<ChoiceSheet :open="choosing" title="选择服务类型" :options="options" :selected="[flow.kind]" @close="choosing = false" @confirm="choose" />
|
||||
<EditDialog :open="editing" title="补充说明(选填)" :value="flow.note" placeholder="请输入需要的帮助或缺少的色号" type="textarea" :maxlength="200" @close="editing = false" @confirm="edit" />
|
||||
</view>
|
||||
</template>
|
||||
<style scoped>
|
||||
.pattern-reference{overflow-wrap:anywhere}
|
||||
</style>
|
||||
@@ -0,0 +1,64 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { onLoad, onShow } from '@dcloudio/uni-app'
|
||||
import { useVisit } from '../../stores/visit'
|
||||
import { showError } from '../../services/api'
|
||||
import { goPage } from '../../core/navigation'
|
||||
import PageReturn from '../../components/ui/PageReturn.vue'
|
||||
import SettingRow from '../../components/ui/SettingRow.vue'
|
||||
import EditDialog from '../../components/ui/EditDialog.vue'
|
||||
let flow = useVisit(), table = ref(''), editing = ref(false), loading = ref(false), error = ref(''), from = ref(''), scanning = ref(false)
|
||||
onLoad(options => { from.value = options?.from || '' })
|
||||
onShow(async () => {
|
||||
loading.value = true; error.value = ''
|
||||
try { await flow.refreshVisit(); if (flow.visit) table.value = flow.visit.tableCode }
|
||||
catch (e) { error.value = e instanceof Error ? e.message : '桌位状态暂不可用' }
|
||||
finally { loading.value = false }
|
||||
})
|
||||
function edit(value: string) { table.value = value.trim(); editing.value = false }
|
||||
async function scan() {
|
||||
if (scanning.value || flow.busy) return
|
||||
scanning.value = true
|
||||
try {
|
||||
let result = await new Promise<UniApp.ScanCodeSuccessRes>((resolve, reject) => uni.scanCode({ success: resolve, fail: reject }))
|
||||
let value = result.result.trim()
|
||||
if (!value || value.length > 40) throw new Error('请扫描店内桌位码')
|
||||
table.value = value
|
||||
uni.showToast({ title: '桌位码已填入,请核对后确认', icon: 'none' })
|
||||
} catch (e) { if (!(e as { errMsg?: string })?.errMsg?.includes('cancel')) showError(e) }
|
||||
finally { scanning.value = false }
|
||||
}
|
||||
async function confirm() {
|
||||
if (flow.busy) return
|
||||
error.value = ''
|
||||
try {
|
||||
let joined = await flow.confirmVisit(table.value)
|
||||
if (!joined) return
|
||||
uni.showToast({ title: '已确认 ' + joined.tableCode + ' 桌', icon: 'none' })
|
||||
if (from.value === 'request') {
|
||||
if (getCurrentPages().length > 1) uni.navigateBack({ delta: 1 })
|
||||
else goPage('packages/store/request')
|
||||
}
|
||||
} catch (e) { error.value = e instanceof Error ? e.message : '到店确认失败'; showError(e) }
|
||||
}
|
||||
async function leave() {
|
||||
if (flow.busy) return
|
||||
let confirmed = await new Promise<boolean>(resolve => uni.showModal({ title: '结束本次到店?', content: '服务记录会保留,再次申请服务需要重新确认桌位。', success: result => resolve(result.confirm), fail: () => resolve(false) }))
|
||||
if (!confirmed) return
|
||||
try { await flow.leave(); uni.showToast({ title: '已结束到店', icon: 'none' }) }
|
||||
catch (e) { showError(e) }
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<view class="page">
|
||||
<PageReturn tab="store" />
|
||||
<view class="page-heading"><view class="eyebrow">TAKE YOUR SEAT</view><view class="title">确认到店</view><view class="muted">核对桌面上的桌位码,方便员工找到你。</view></view>
|
||||
<view v-if="error" class="error" role="alert">{{ error }}</view>
|
||||
<view v-if="loading" class="muted" role="status">正在核对当前桌位…</view>
|
||||
<view v-if="flow.visit" class="panel"><text class="chip">本次到店已确认</text><view class="section-title">{{ flow.visit.tableCode }} 桌</view><view class="muted">如需换座,可以修改桌位码后再次确认。</view></view>
|
||||
<view class="settings-group"><SettingRow label="桌位码" :value="table || '点击填写桌位码'" description="例如 T01,以店内桌面标识为准" :disabled="flow.busy || loading" @activate="editing = true" /><SettingRow label="扫描桌位码" value="打开相机" description="扫码后先核对,再确认到店" :disabled="flow.busy || scanning" @activate="scan" /></view>
|
||||
<view class="action-footer"><button class="primary" :disabled="flow.busy || loading || !table.trim()" :loading="flow.busy" @tap="confirm">{{ flow.visit && flow.visit.tableCode === table.trim() ? '确认当前桌位' : '确认到店' }}</button></view>
|
||||
<view v-if="flow.visit" class="settings-group mt"><SettingRow label="结束本次到店" value="离开门店" description="结束后停止关联当前桌位" danger :disabled="flow.busy" @activate="leave" /></view>
|
||||
<EditDialog :open="editing" title="填写桌位码" :value="table" placeholder="请输入桌面上的桌位码" :maxlength="40" @close="editing = false" @confirm="edit" />
|
||||
</view>
|
||||
</template>
|
||||
+133
@@ -0,0 +1,133 @@
|
||||
{
|
||||
"pages": [
|
||||
{
|
||||
"path": "pages/gallery/index",
|
||||
"style": {
|
||||
"navigationBarTitleText": "豆间 · 拼豆工坊"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/make/index",
|
||||
"style": {
|
||||
"navigationBarTitleText": "制作图纸"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/store/index",
|
||||
"style": {
|
||||
"navigationBarTitleText": "门店服务"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/mine/index",
|
||||
"style": {
|
||||
"navigationBarTitleText": "我的"
|
||||
}
|
||||
}
|
||||
],
|
||||
"globalStyle": {
|
||||
"navigationBarTextStyle": "black",
|
||||
"navigationBarBackgroundColor": "#f7f5ee",
|
||||
"backgroundColor": "#f7f5ee"
|
||||
},
|
||||
"tabBar": {
|
||||
"color": "#777b72",
|
||||
"selectedColor": "#265b49",
|
||||
"backgroundColor": "#fffdf7",
|
||||
"borderStyle": "white",
|
||||
"list": [
|
||||
{
|
||||
"pagePath": "pages/gallery/index",
|
||||
"text": "图纸"
|
||||
},
|
||||
{
|
||||
"pagePath": "pages/make/index",
|
||||
"text": "制作"
|
||||
},
|
||||
{
|
||||
"pagePath": "pages/store/index",
|
||||
"text": "门店"
|
||||
},
|
||||
{
|
||||
"pagePath": "pages/mine/index",
|
||||
"text": "我的"
|
||||
}
|
||||
]
|
||||
},
|
||||
"subPackages": [
|
||||
{
|
||||
"root": "packages/creation",
|
||||
"pages": [
|
||||
{
|
||||
"path": "config",
|
||||
"style": {
|
||||
"navigationBarTitleText": "创作设置"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "editor",
|
||||
"style": {
|
||||
"navigationBarTitleText": "编辑图纸"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"root": "packages/patterns",
|
||||
"pages": [
|
||||
{
|
||||
"path": "detail",
|
||||
"style": {
|
||||
"navigationBarTitleText": "跟着图纸拼"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "materials",
|
||||
"style": {
|
||||
"navigationBarTitleText": "用料清单"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "print",
|
||||
"style": {
|
||||
"navigationBarTitleText": "打印图纸"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"root": "packages/store",
|
||||
"pages": [
|
||||
{
|
||||
"path": "visit",
|
||||
"style": {
|
||||
"navigationBarTitleText": "确认到店"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "request",
|
||||
"style": {
|
||||
"navigationBarTitleText": "申请服务"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "records",
|
||||
"style": {
|
||||
"navigationBarTitleText": "服务记录"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"root": "packages/account",
|
||||
"pages": [
|
||||
{
|
||||
"path": "library",
|
||||
"style": {
|
||||
"navigationBarTitleText": "我的图纸"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
<script setup lang="ts">
|
||||
import {computed,ref} from 'vue'
|
||||
import {onShow} from '@dcloudio/uni-app'
|
||||
import {api,storeId} from '../../services/api'
|
||||
import {goPage} from '../../core/navigation'
|
||||
import type {Pattern} from '../../core/types'
|
||||
import PatternCanvas from '../../components/PatternCanvas.vue'
|
||||
import SettingRow from '../../components/ui/SettingRow.vue'
|
||||
import ChoiceSheet from '../../components/ui/ChoiceSheet.vue'
|
||||
let patterns=ref<Pattern[]>([]),query=ref(''),category=ref('全部'),limit=ref('0'),loading=ref(false),error=ref(''),sheet=ref('')
|
||||
let options=computed(()=>sheet.value==='category'?['全部',...new Set(patterns.value.map(p=>p.category))].map(c=>({value:c,label:c})):[{value:'0',label:'不限颗数'},{value:'500',label:'500 颗以内'},{value:'1000',label:'1,000 颗以内'}])
|
||||
let visible=computed(()=>patterns.value.filter(p=>(category.value==='全部'||p.category===category.value)&&(!Number(limit.value)||p.beadCount<=Number(limit.value))&&p.title.toLowerCase().includes(query.value.toLowerCase())))
|
||||
async function load(){loading.value=true;error.value='';try{patterns.value=await api<Pattern[]>('/patterns?storeId='+storeId)}catch(e){error.value=(e as Error).message}finally{loading.value=false}}
|
||||
function select(values:string[]){if(sheet.value==='category')category.value=values[0];else limit.value=values[0];sheet.value=''}
|
||||
onShow(load)
|
||||
</script>
|
||||
<template><view class="page"><view class="page-heading"><view class="eyebrow">A LITTLE BEAD, A LITTLE JOY</view><view class="title">把喜欢的,<br/>一颗颗拼出来。</view><view class="muted">选一张图纸,留一段专注的时间。</view></view>
|
||||
<input v-model="query" class="search" aria-label="搜索图纸" placeholder="搜索图纸名称"/>
|
||||
<view class="settings-group"><SettingRow label="图纸分类" :value="category" @activate="sheet='category'"/><SettingRow label="用豆数量" :value="limit==='0'?'不限颗数':Number(limit).toLocaleString()+' 颗以内'" @activate="sheet='limit'"/></view>
|
||||
<view class="row spread section-title"><text>图纸小集</text><text class="muted">{{visible.length}} 个灵感</text></view>
|
||||
<view v-if="error" class="error">{{error}}<button @tap="load">重新加载</button></view><view v-else-if="loading" class="empty">正在打开图纸册…</view><view v-else-if="!visible.length" class="empty"><view class="empty-title">暂时没有找到</view>换个关键词或筛选条件试试。</view>
|
||||
<view v-else class="grid"><view v-for="p in visible" :key="p.id" class="art"><view class="preview"><PatternCanvas :pattern="p" :size="116"/></view><view class="art-info"><view class="art-title">{{p.title}}</view><view class="muted">{{p.width}} × {{p.height}} 格 · {{p.beadCount}} 颗</view><button class="view-button" :aria-label="'查看'+p.title" @tap="goPage('packages/patterns/detail',{id:p.id})">查看图纸 →</button></view></view></view>
|
||||
<view class="page-note">DEMO 色号为演示色板,到店制作请核对实际品牌与色号。</view>
|
||||
<ChoiceSheet :open="!!sheet" :title="sheet==='category'?'图纸分类':'用豆数量'" :options="options" :selected="[sheet==='category'?category:limit]" @close="sheet=''" @confirm="select"/>
|
||||
</view></template>
|
||||
<style scoped>.search{margin-bottom:16px}.preview{height:150px;display:flex;justify-content:center;align-items:center;background-image:radial-gradient(#d6dccb 1px,transparent 1px);background-size:10px 10px}.view-button{font-size:12px;margin-top:8px;background:transparent;min-height:44px;text-align:left;padding:0}.view-button::after{border:0}</style>
|
||||
@@ -0,0 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import SettingRow from '../../components/ui/SettingRow.vue'
|
||||
import {goPage} from '../../core/navigation'
|
||||
import {useCreation} from '../../stores/creation'
|
||||
import {showError} from '../../services/api'
|
||||
let creation=useCreation()
|
||||
function restore(){try{creation.restore();goPage('packages/creation/editor')}catch(e){showError(e)}}
|
||||
</script>
|
||||
<template><view class="page"><view class="page-heading"><view class="eyebrow">YOUR PIXELS, YOUR STORY</view><view class="title">开始一份新创作</view><view class="muted">选一种灵感,慢慢把它拼成作品。</view></view><view class="section-title mt">创作方式</view><view class="settings-group mt"><SettingRow label="从图片开始" value="选择图片" description="本地裁剪与色板量化,原始图片不上传" @activate="goPage('packages/creation/config',{mode:'image'})"/><SettingRow label="把文字变成图纸" value="输入文字" description="名字、短句或一个喜欢的字" @activate="goPage('packages/creation/config',{mode:'text'})"/><SettingRow label="在空白画布手绘" value="自由创作" description="逐格画出你的想法" @activate="goPage('packages/creation/config',{mode:'blank'})"/></view><view class="section-title mt">继续创作</view><view class="settings-group mt"><SettingRow label="恢复本地草稿" value="继续编辑" description="恢复上次自动保存的网格;清除应用数据会移除草稿" @activate="restore"/></view><view class="hint mt">保存作品不会上传原始图片。实际颜色以门店色板为准。</view></view></template>
|
||||
@@ -0,0 +1,18 @@
|
||||
<script setup lang="ts">
|
||||
import {ref} from 'vue'
|
||||
import {onShow} from '@dcloudio/uni-app'
|
||||
import {showError} from '../../services/api'
|
||||
import {useSession} from '../../stores/session'
|
||||
import {goPage,goMain} from '../../core/navigation'
|
||||
import SettingRow from '../../components/ui/SettingRow.vue'
|
||||
let session=useSession(),busy=ref(false)
|
||||
onShow(async()=>{if(uni.getStorageSync('access-token'))try{await session.ensure()}catch(e){showError(e)}})
|
||||
async function login(){if(busy.value)return;busy.value=true;try{await session.login()}catch(e){showError(e)}finally{busy.value=false}}
|
||||
function logout(){uni.showModal({title:'退出登录',content:'账号作品会保留,本机草稿也不会删除。',success:r=>{if(r.confirm)session.logout()}})}
|
||||
</script>
|
||||
<template><view class="page"><view class="page-heading"><view class="eyebrow">MADE BY YOU</view><view class="title">每一颗,都算数。</view><view class="muted">收藏灵感,也收藏专注的时光。</view></view>
|
||||
<view class="profile panel"><view class="avatar">豆</view><view class="grow"><view class="profile-name">{{session.user?.name||'欢迎来到豆间'}}</view><view class="muted">{{session.user?'今天也来拼一点喜欢的。':'登录后保存作品与收藏'}}</view></view><button v-if="!session.user" class="primary" :loading="busy" :disabled="busy" @tap="login">登录</button></view>
|
||||
<view class="section-title">我的图纸</view><view class="settings-group"><SettingRow label="我的作品" description="保存在账号里的创作" @activate="goPage('packages/account/library',{type:'works'})"/><SettingRow label="我的收藏" description="下次想拼的,都在这里" @activate="goPage('packages/account/library',{type:'favorites'})"/><SettingRow label="本地草稿" description="回到制作页,继续未完成的作品" @activate="goMain('make')"/></view>
|
||||
<view class="section-title">到店服务</view><view class="settings-group"><SettingRow label="服务记录" description="查看呼叫进度与历史申请" @activate="goPage('packages/store/records')"/></view>
|
||||
<view v-if="session.user" class="settings-group"><SettingRow label="退出登录" danger @activate="logout"/></view><view class="page-note">草稿和跟做进度保存在本机,账号作品保存在店铺服务器。</view>
|
||||
</view></template><style scoped>.profile{display:flex;align-items:center;gap:14px;padding:20px 16px}.avatar{width:44px;height:44px;flex-shrink:0;background:#e4e9d9;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:20px;color:#436448}.profile-name{font-size:16px;font-weight:600;margin-bottom:4px}.profile button{padding:0 16px;flex-shrink:0}</style>
|
||||
@@ -0,0 +1,45 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { onShow } from '@dcloudio/uni-app'
|
||||
import { api, storeId, network } from '../../services/api'
|
||||
import { goPage } from '../../core/navigation'
|
||||
import { useVisit } from '../../stores/visit'
|
||||
import SettingRow from '../../components/ui/SettingRow.vue'
|
||||
import type { Store } from '../../core/types'
|
||||
let flow = useVisit(), store = ref<Store | null>(null), loading = ref(false), error = ref('')
|
||||
let tableStatus = computed(() => flow.visit ? flow.visit.tableCode + ' 桌 · 已确认' : '尚未确认桌位')
|
||||
async function load() {
|
||||
if (loading.value) return
|
||||
loading.value = true; error.value = ''
|
||||
try {
|
||||
let [stores, currentVisit] = await Promise.allSettled([api<Store[]>('/stores'), flow.refreshVisit()])
|
||||
if (stores.status === 'fulfilled') store.value = stores.value.find(item => item.id === storeId) || null
|
||||
else throw stores.reason
|
||||
if (currentVisit.status === 'rejected') throw currentVisit.reason
|
||||
} catch (e) { error.value = e instanceof Error ? e.message : '门店信息暂不可用' }
|
||||
finally { loading.value = false }
|
||||
}
|
||||
onShow(load)
|
||||
</script>
|
||||
<template>
|
||||
<view class="page">
|
||||
<view class="page-heading"><view class="eyebrow">YOUR SLOW AFTERNOON</view><view class="title">在店里的时光</view><view class="muted">入座后,把需要的帮助交给我们。</view></view>
|
||||
<view v-if="error" class="error" role="alert">{{ error }}<button class="mt" :disabled="loading" @tap="load">重新获取门店信息</button></view>
|
||||
<view v-if="loading && !store" class="empty" role="status">正在获取门店信息…</view>
|
||||
<view v-else-if="!store && !error" class="empty">暂无门店信息,请稍后再来。</view>
|
||||
<view v-if="store" class="panel">
|
||||
<view class="row spread"><text class="store-name">{{ store.name }}</text><text class="chip">{{ network.mode === 'local' ? '店内直连' : '在线服务' }}</text></view>
|
||||
<view class="muted mt">{{ store.address }}</view><view class="muted">营业时间 {{ store.hours }}</view>
|
||||
<view class="hint mt">{{ store.notice || '慢慢拼,需要帮助时随时呼叫我们。' }}</view>
|
||||
</view>
|
||||
<view class="panel"><text class="muted">当前桌位</text><view class="table-status">{{ tableStatus }}</view><view class="muted">{{ flow.visit ? '本次服务会送到这个桌位。' : '请先核对桌面上的桌位码。' }}</view></view>
|
||||
<view class="settings-group">
|
||||
<SettingRow label="确认到店" :value="flow.visit ? flow.visit.tableCode + ' 桌' : '未确认'" description="核对座位,或结束本次到店" @activate="goPage('packages/store/visit')" />
|
||||
<SettingRow label="申请服务" value="选择服务" description="选择需要的帮助,再确认提交" @activate="goPage('packages/store/request')" />
|
||||
<SettingRow label="服务记录" value="查看处理进度" description="等待接单时,可在记录中取消申请" @activate="goPage('packages/store/records')" />
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
<style scoped>
|
||||
.store-name{font-size:18px;font-weight:600;min-width:0;overflow-wrap:anywhere}.table-status{font-size:22px;font-weight:600;margin:6px 0;overflow-wrap:anywhere}.row.spread{align-items:flex-start;flex-wrap:wrap}
|
||||
</style>
|
||||
@@ -0,0 +1,52 @@
|
||||
import { reactive } from 'vue'
|
||||
import { BusinessError,requestWithFallback } from '../core/transport'
|
||||
import { EndpointRouter } from '../core/network'
|
||||
let remote = (import.meta.env.VITE_PUBLIC_API_BASE || (import.meta.env.DEV ? 'http://127.0.0.1:8080' : '')).replace(/\/$/,'')
|
||||
let local = (import.meta.env.VITE_LOCAL_API_BASE || '').replace(/\/$/,'')
|
||||
export let storeId = import.meta.env.VITE_STORE_ID || 'store-001'
|
||||
export let network = reactive({ mode: 'connecting', error: '' })
|
||||
function validEndpoint(url: string) { return /^https:\/\/[^/]+$/.test(url) || (import.meta.env.DEV && /^http:\/\/(localhost|127\.0\.0\.1)(:\d+)?$/.test(url)) }
|
||||
function raw<T>(base: string,path: string,method: string,data?: unknown,timeout=12000,auth=true): Promise<T> {
|
||||
if (!validEndpoint(base)) return Promise.reject(new Error('请配置有效的 HTTPS 服务域名'))
|
||||
return new Promise((resolve,reject) => uni.request({ url:base+'/api'+path,method:method as 'GET',data:data as any,timeout,
|
||||
header: auth ? { Authorization: 'Bearer '+(uni.getStorageSync('access-token') || '') } : {},
|
||||
success: res => {
|
||||
let body = res.data as {code:number;msg:string;data:T}
|
||||
if (res.statusCode<200 || res.statusCode>=300 || !body || typeof body.code !== 'number') { reject(new Error('服务器响应异常')); return }
|
||||
if (body.code !== 200) { if (body.code===403 && auth) uni.removeStorageSync('access-token'); reject(new BusinessError(body.msg,body.code)); return }
|
||||
resolve(body.data)
|
||||
}, fail: () => reject(new Error('连接失败,请检查网络后重试')) }))
|
||||
}
|
||||
let router = new EndpointRouter({remote,local,storeId}, async base => {
|
||||
let info = await raw<{service:string;storeId:string}>(base,'/health','GET',undefined,1000,false)
|
||||
return info.service === 'perler-beads' ? info.storeId : ''
|
||||
})
|
||||
function reflect() { network.mode = router.current === local && local ? 'local' : 'public' }
|
||||
async function select() {
|
||||
let type = await new Promise<string>(resolve => uni.getNetworkType({success:r=>resolve(r.networkType),fail:()=>resolve('unknown')}))
|
||||
let base = await router.select(type); reflect(); return base
|
||||
}
|
||||
let listening = false
|
||||
export function listenNetwork() {
|
||||
if (listening) return; listening = true
|
||||
uni.onNetworkStatusChange(r => { router.select(r.isConnected ? r.networkType : 'none').then(reflect).catch(()=>{network.mode='offline'}) })
|
||||
}
|
||||
export async function api<T>(path:string,method='GET',data?:unknown): Promise<T> {
|
||||
try {
|
||||
let base = await select()
|
||||
return await requestWithFallback<T>(base,local,remote,path,method,data,raw,()=>{router.fallback();reflect()})
|
||||
} catch(e) { network.error = e instanceof Error ? e.message : '请求失败'; throw e }
|
||||
}
|
||||
export async function downloadPrint(id: string,pitchMm=5) {
|
||||
let base = await select()
|
||||
let url = base+'/api/patterns/'+encodeURIComponent(id)+'/print?pitchMm='+pitchMm
|
||||
// #ifdef H5
|
||||
let response = await fetch(url,{headers:{Authorization:'Bearer '+uni.getStorageSync('access-token')}})
|
||||
if (!response.ok || !response.headers.get('content-type')?.includes('image/svg')) throw new Error('打印文件生成失败,请确认图纸访问权限')
|
||||
let href = URL.createObjectURL(await response.blob()); let link = document.createElement('a'); link.href=href; link.download='拼豆图纸.svg'; link.click(); setTimeout(()=>URL.revokeObjectURL(href),1000)
|
||||
// #endif
|
||||
// #ifdef MP-WEIXIN
|
||||
uni.showModal({title:'门店打印',content:'请在门店页提交打印申请,由店员下载实际尺寸图纸打印。',showCancel:false})
|
||||
// #endif
|
||||
}
|
||||
export function showError(e: unknown) { uni.showToast({title:e instanceof Error ? e.message : '操作失败,请重试',icon:'none',duration:3000}) }
|
||||
@@ -0,0 +1,5 @@
|
||||
import {defineStore} from 'pinia'
|
||||
import {ref,computed} from 'vue'
|
||||
import type {Color,Pattern} from '../core/types'
|
||||
import {readDraft} from '../core/creation'
|
||||
export let useCreation=defineStore('creation',()=>{let mode=ref<'image'|'text'|'blank'>('image'),title=ref('我的拼豆作品'),width=ref(29),height=ref(29),text=ref('豆'),colors=ref<Color[]>([]),excluded=ref<string[]>([]),paint=ref<string|null>(null),imagePath=ref(''),imageInfo=ref({width:1,height:1}),pattern=ref<Pattern|null>(null);let activeColors=computed(()=>colors.value.filter(c=>c.available&&!excluded.value.includes(c.code)));function start(value:string){mode.value=value==='text'?'text':value==='blank'?'blank':'image';pattern.value=null;imagePath.value=''}function persist(){if(pattern.value)uni.setStorageSync('pattern-draft',JSON.parse(JSON.stringify(pattern.value)))}function restore(){let p=readDraft(uni.getStorageSync('pattern-draft'));pattern.value=p;title.value=p.title;width.value=p.width;height.value=p.height;paint.value=p.colors.find(c=>c.available)?.code||null}function edit(index:number){let p=pattern.value;if(!p||!Number.isInteger(index)||index<0||index>=p.cells.length)return;let c=colors.value.find(c=>c.code===paint.value&&c.available);if(paint.value&&!c)return;p.cells[index]=paint.value;if(c&&!p.colors.some(v=>v.code===c.code))p.colors.push({...c});p.beadCount=p.cells.filter(v=>v!==null).length;persist()}return{mode,title,width,height,text,colors,excluded,paint,imagePath,imageInfo,pattern,activeColors,start,persist,restore,edit}})
|
||||
@@ -0,0 +1,25 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import { api } from '../services/api'
|
||||
import type { User } from '../core/types'
|
||||
export let useSession = defineStore('session',()=>{
|
||||
let user = ref<User|null>(null)
|
||||
async function login() {
|
||||
let code = ''
|
||||
if (import.meta.env.DEV && import.meta.env.VITE_DEV_LOGIN === 'true') code='dev-guest'
|
||||
else {
|
||||
// #ifdef MP-WEIXIN
|
||||
code = await new Promise<string>((resolve,reject)=>uni.login({provider:'weixin',success:r=>resolve(r.code),fail:reject}))
|
||||
// #endif
|
||||
if (!code) throw new Error('请在微信小程序中登录;本地联调需开启开发登录')
|
||||
}
|
||||
let data = await api<{token:string;user:User}>('/auth/login','POST',{code})
|
||||
uni.setStorageSync('access-token',data.token); user.value=data.user
|
||||
}
|
||||
async function ensure() {
|
||||
if (!uni.getStorageSync('access-token')) await login()
|
||||
else if(!user.value) user.value=await api<User>('/me')
|
||||
}
|
||||
function logout() { user.value=null; uni.removeStorageSync('access-token') }
|
||||
return {user,login,ensure,logout}
|
||||
})
|
||||
@@ -0,0 +1,143 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, watch } from 'vue'
|
||||
import { api, storeId } from '../services/api'
|
||||
import { submitAndRefresh } from '../core/transport'
|
||||
import { useSession } from './session'
|
||||
import type { ServiceRequest, Visit } from '../core/types'
|
||||
|
||||
type Kind = ServiceRequest['kind']
|
||||
type Payload = { kind: Kind; note: string; patternId?: string; requestId: string }
|
||||
export let serviceNames: Record<Kind, string> = { help: '呼叫员工', refill: '补充豆子', ironing: '熨烫作品', print: '打印图纸' }
|
||||
export let serviceStatuses: Record<ServiceRequest['status'], string> = { pending: '等待接单', accepted: '员工已接单', completed: '服务已完成', cancelled: '已取消' }
|
||||
function uuid() {
|
||||
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, c => {
|
||||
let r = Math.floor(Math.random() * 16)
|
||||
return (c === 'x' ? r : (r & 3) | 8).toString(16)
|
||||
})
|
||||
}
|
||||
export function createVisitWorkflow(deps: {api: typeof api;storeId: string;identity: () => string;ensure: () => Promise<void>}) {
|
||||
let {api,storeId} = deps
|
||||
let visit = ref<Visit | null>(null), tasks = ref<ServiceRequest[]>([])
|
||||
let kind = ref<Kind>('help'), note = ref(''), patternId = ref('')
|
||||
let busy = ref(false), submitted = ref<ServiceRequest | null>(null), refreshError = ref('')
|
||||
let pending: { signature: string; body: Payload } | null = null
|
||||
let visitEpoch = 0, tasksEpoch = 0
|
||||
let owner = deps.identity(), scopeEpoch = 0, operationEpoch = 0
|
||||
function clearResult() { submitted.value = null; refreshError.value = ''; pending = null }
|
||||
function syncIdentity() {
|
||||
let current = deps.identity()
|
||||
if (current !== owner) {
|
||||
let previous = owner; owner = current; ++scopeEpoch; ++operationEpoch; ++visitEpoch; ++tasksEpoch
|
||||
visit.value = null; tasks.value = []; busy.value = false; clearResult()
|
||||
// 首次登录前填写的申请草稿保留;离开已登录账号后清除其私有输入。
|
||||
if (previous) { note.value = ''; kind.value = 'help'; patternId.value = '' }
|
||||
}
|
||||
return scopeEpoch
|
||||
}
|
||||
function currentScope(epoch: number) { return syncIdentity() === epoch }
|
||||
function assertScope(epoch: number, visitId?: string) {
|
||||
if (!currentScope(epoch) || (visitId !== undefined && visit.value?.id !== visitId)) throw new Error('账号或到店会话已变化,请重新确认')
|
||||
}
|
||||
function applyVisit(current: Visit | null) {
|
||||
if (visit.value?.id !== current?.id) clearResult()
|
||||
visit.value = current
|
||||
}
|
||||
|
||||
async function refreshVisit() {
|
||||
let scope = syncIdentity()
|
||||
let epoch = ++visitEpoch
|
||||
if (!deps.identity()) {
|
||||
visit.value = null; tasks.value = []; pending = null; submitted.value = null
|
||||
return
|
||||
}
|
||||
try {
|
||||
let current = await api<Visit | null>('/visits/current')
|
||||
if (currentScope(scope) && epoch === visitEpoch) applyVisit(current?.status === 'active' && current.storeId === storeId ? current : null)
|
||||
} catch (e) {
|
||||
if (!currentScope(scope) || epoch !== visitEpoch) return
|
||||
if (!deps.identity()) applyVisit(null)
|
||||
throw e
|
||||
}
|
||||
}
|
||||
async function refreshTasks() {
|
||||
let scope = syncIdentity()
|
||||
let epoch = ++tasksEpoch
|
||||
if (!deps.identity()) { tasks.value = []; return }
|
||||
let current = await api<ServiceRequest[]>('/service-requests')
|
||||
if (currentScope(scope) && epoch === tasksEpoch) tasks.value = current
|
||||
}
|
||||
async function confirmVisit(table: string) {
|
||||
syncIdentity()
|
||||
let previousOwner = owner
|
||||
let code = table.trim()
|
||||
if (!code || code.length > 40) throw new Error('请输入有效的店内桌位码')
|
||||
if (busy.value) return
|
||||
busy.value = true
|
||||
let operation = ++operationEpoch
|
||||
try {
|
||||
await deps.ensure()
|
||||
let scope = syncIdentity()
|
||||
if (previousOwner && previousOwner !== owner) throw new Error('账号已变化,请重新确认')
|
||||
operation = ++operationEpoch; busy.value = true
|
||||
let joined = await api<Visit>('/visits', 'POST', { storeId, tableCode: code })
|
||||
assertScope(scope)
|
||||
++visitEpoch; applyVisit(joined)
|
||||
return joined
|
||||
} finally { syncIdentity(); if (operation === operationEpoch) busy.value = false }
|
||||
}
|
||||
async function leave() {
|
||||
let scope = syncIdentity()
|
||||
if (busy.value) return
|
||||
busy.value = true
|
||||
let operation = ++operationEpoch
|
||||
try {
|
||||
await api('/visits/current', 'DELETE')
|
||||
assertScope(scope)
|
||||
++visitEpoch; applyVisit(null); clearResult()
|
||||
} finally { syncIdentity(); if (operation === operationEpoch) busy.value = false }
|
||||
}
|
||||
function configureRequest(options: { kind?: string; patternId?: string }) {
|
||||
syncIdentity()
|
||||
if (options.kind && Object.prototype.hasOwnProperty.call(serviceNames, options.kind)) kind.value = options.kind as Kind
|
||||
if (options.patternId) { patternId.value = options.patternId; kind.value = 'print' }
|
||||
if (options.kind || options.patternId) { submitted.value = null; refreshError.value = '' }
|
||||
}
|
||||
function startNewRequest() {
|
||||
syncIdentity()
|
||||
submitted.value = null; refreshError.value = ''; pending = null; note.value = ''
|
||||
}
|
||||
async function submit() {
|
||||
let scope = syncIdentity()
|
||||
// 已确认提交的结果不能因页面刷新失败或再次点击变成第二次呼叫。
|
||||
if (submitted.value) return { task: submitted.value, refreshError: refreshError.value }
|
||||
if (busy.value) throw new Error('正在提交,请稍候')
|
||||
if (!visit.value || visit.value.storeId !== storeId) throw new Error('请先确认本门店桌位')
|
||||
if (kind.value === 'print' && !patternId.value) throw new Error('请先从图纸详情关联打印图纸')
|
||||
let payload = { kind: kind.value, note: note.value.trim(), ...(kind.value === 'print' ? { patternId: patternId.value } : {}) }
|
||||
let signature = JSON.stringify({ ...payload, visitId: visit.value.id })
|
||||
if (!pending || pending.signature !== signature) pending = { signature, body: { ...payload, requestId: uuid() } }
|
||||
let body = pending.body, visitId = visit.value.id
|
||||
let operation = ++operationEpoch
|
||||
busy.value = true
|
||||
try {
|
||||
let result = await submitAndRefresh(
|
||||
async () => { let task = await api<ServiceRequest>('/service-requests', 'POST', body); assertScope(scope, visitId); return task },
|
||||
task => {
|
||||
submitted.value = task; pending = null
|
||||
tasks.value = [task, ...tasks.value.filter(existing => existing.id !== task.id)]
|
||||
}, refreshTasks
|
||||
)
|
||||
assertScope(scope, visitId)
|
||||
refreshError.value = result.refreshError
|
||||
return result
|
||||
} finally { syncIdentity(); if (operation === operationEpoch) busy.value = false }
|
||||
}
|
||||
return { visit, tasks, kind, note, patternId, busy, submitted, refreshError, refreshVisit, refreshTasks, confirmVisit, leave, configureRequest, startNewRequest, submit, syncIdentity }
|
||||
}
|
||||
export let useVisit = defineStore('visit', () => {
|
||||
let session = useSession()
|
||||
let workflow = createVisitWorkflow({ api, storeId, identity: () => String(uni.getStorageSync('access-token') || ''), ensure: session.ensure })
|
||||
// session.logout 先改 user 再移除 token,使用默认的批处理时机读取最终身份。
|
||||
watch(() => session.user?.id, () => workflow.syncIdentity())
|
||||
return workflow
|
||||
})
|
||||
@@ -0,0 +1,8 @@
|
||||
import {test} from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import {cellAtPoint} from '../src/core/canvas.ts'
|
||||
test('页面滚动和画布偏移后仍准确定位格子',()=>{
|
||||
assert.equal(cellAtPoint(125,345,{left:100,top:300,width:100,height:100},10,10),42)
|
||||
assert.equal(cellAtPoint(125,45,{left:100,top:0,width:100,height:100},10,10),42)
|
||||
assert.equal(cellAtPoint(125,-1,{left:100,top:0,width:100,height:100},10,10),null)
|
||||
})
|
||||
@@ -0,0 +1,5 @@
|
||||
import {test} from 'node:test'
|
||||
import {strict as assert} from 'node:assert'
|
||||
import {creationSize,readDraft} from '../src/core/creation.ts'
|
||||
test('制作尺寸拒绝小数、空值与超限',()=>{assert.equal(creationSize('29'),29);for(let value of ['', '8.5','97','7'])assert.throws(()=>creationSize(value))})
|
||||
test('草稿恢复允许空白画布并拒绝未知色号与损坏格数',()=>{let draft={title:'草稿',width:8,height:8,cells:Array(64).fill(null),colors:[]};assert.equal(readDraft(draft).cells.length,64);assert.throws(()=>readDraft({...draft,cells:['BAD',...Array(63).fill(null)]}));assert.throws(()=>readDraft({...draft,cells:[]}))})
|
||||
@@ -0,0 +1,5 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import {route,mainRoute,returnAction} from '../src/core/navigation.ts'
|
||||
test('分包参数编码,空参数省略',()=>{assert.equal(route('packages/patterns/detail',{id:'a?b&c',empty:undefined}),'/packages/patterns/detail?id=a%3Fb%26c');assert.equal(route('/packages/account/library',{type:'works'}),'/packages/account/library?type=works')})
|
||||
test('主包返回使用tab路由,无历史时不navigateBack',()=>{assert.equal(mainRoute('make'),'/pages/make/index');assert.deepEqual(returnAction(1,'store'),{type:'tab',url:'/pages/store/index'});assert.deepEqual(returnAction(2,'mine'),{type:'back'})})
|
||||
@@ -0,0 +1,39 @@
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { EndpointRouter } from '../src/core/network.ts'
|
||||
|
||||
let local = 'https://lan.example.com'
|
||||
let remote = 'https://api.example.com'
|
||||
test('蜂窝网络与未知网络不探测内网', async () => {
|
||||
for (let type of ['4g', '5g', 'unknown']) {
|
||||
let probes = 0
|
||||
let r = new EndpointRouter({ local, remote, storeId: 's1' }, async () => { probes++; return 's1' })
|
||||
assert.equal(await r.select(type), remote)
|
||||
assert.equal(probes, 0)
|
||||
}
|
||||
})
|
||||
test('Wi-Fi 只有对应门店探测成功才走内网,失败进入冷却', async () => {
|
||||
let probes = 0
|
||||
let r = new EndpointRouter({ local, remote, storeId: 's1' }, async () => { probes++; return 's2' })
|
||||
assert.equal(await r.select('wifi'), remote)
|
||||
assert.equal(await r.select('wifi'), remote)
|
||||
assert.equal(probes, 1)
|
||||
let good = new EndpointRouter({ local, remote, storeId: 's1' }, async () => 's1')
|
||||
assert.equal(await good.select('wifi'), local)
|
||||
})
|
||||
test('网络切换后丢弃过期内网探测结果', async () => {
|
||||
let resolve!: (value: string) => void
|
||||
let r = new EndpointRouter({ local, remote, storeId: 's1' }, () => new Promise(res => { resolve = res }))
|
||||
let old = r.select('wifi')
|
||||
assert.equal(await r.select('4g'), remote)
|
||||
resolve('s1')
|
||||
assert.equal(await old, remote)
|
||||
assert.equal(r.current, remote)
|
||||
})
|
||||
test('无网络明确离线;连通失败切回公网', async () => {
|
||||
let r = new EndpointRouter({ local, remote, storeId: 's1' }, async () => 's1')
|
||||
await assert.rejects(r.select('none'), /网络/)
|
||||
await r.select('wifi')
|
||||
r.fallback()
|
||||
assert.equal(r.current, remote)
|
||||
})
|
||||
@@ -0,0 +1,23 @@
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { quantize, countBeads, validateGrid, cropRect } from '../src/core/pattern.ts'
|
||||
let colors = [{ code: 'B', name: '黑', hex: '#000000', location: '', available: true }, { code: 'W', name: '白', hex: '#ffffff', location: '', available: true }]
|
||||
test('透明像素不是白色豆;统计仅包括实际豆子', () => {
|
||||
let cells = quantize(new Uint8ClampedArray([255,255,255,255, 0,0,0,255, 255,255,255,0]), colors)
|
||||
assert.deepEqual(cells, ['W','B',null])
|
||||
assert.deepEqual(countBeads(cells), { W: 1, B: 1 })
|
||||
})
|
||||
test('色板量化使用最近的可用颜色', () => {
|
||||
assert.deepEqual(quantize(new Uint8ClampedArray([230,230,230,255]), colors), ['W'])
|
||||
assert.throws(() => quantize(new Uint8ClampedArray([0,0,0,255]), []), /色板/)
|
||||
})
|
||||
test('非法尺寸、未知色号、空图不能保存', () => {
|
||||
assert.throws(() => validateGrid(2, 2, ['W'], colors), /网格/)
|
||||
assert.throws(() => validateGrid(1, 1, ['X'], colors), /色号/)
|
||||
assert.throws(() => validateGrid(1, 1, [null], colors), /空/)
|
||||
assert.doesNotThrow(() => validateGrid(1, 1, ['W'], colors))
|
||||
})
|
||||
test('中心裁剪保持目标比例', () => {
|
||||
assert.deepEqual(cropRect(400,200,1), { x:100,y:0,width:200,height:200 })
|
||||
assert.deepEqual(cropRect(200,400,1), { x:0,y:100,width:200,height:200 })
|
||||
})
|
||||
@@ -0,0 +1,20 @@
|
||||
import {test} from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import {BusinessError,requestWithFallback,submitAndRefresh} from '../src/core/transport.ts'
|
||||
test('内网写入结果不明时,公网重放保持相同请求标识与内容',async()=>{
|
||||
let body={requestId:'one-key',kind:'help'},calls:any[]=[]
|
||||
let task=await requestWithFallback('lan','lan','public','/service-requests','POST',body,async(base:string,_path:string,_method:string,data:any)=>{calls.push({base,data});if(base==='lan')throw new Error('timeout');return{id:'same-task'}},()=>{})
|
||||
assert.deepEqual(task,{id:'same-task'});assert.equal(calls.length,2);assert.equal(calls[0].data,calls[1].data)
|
||||
})
|
||||
test('业务错误不切换、不重放;无幂等键的写入不自动重试',async()=>{
|
||||
let attempts=0
|
||||
await assert.rejects(requestWithFallback('lan','lan','public','/patterns','POST',{},async()=>{attempts++;throw new BusinessError('无权限',401)},()=>{}),/无权限/)
|
||||
assert.equal(attempts,1)
|
||||
await assert.rejects(requestWithFallback('lan','lan','public','/patterns','POST',{},async()=>{attempts++;throw new Error('timeout')},()=>{}),/确认记录/)
|
||||
assert.equal(attempts,2)
|
||||
})
|
||||
test('提交成功但刷新失败仍保留已提交任务,不误报提交失败',async()=>{
|
||||
let committed:any=null
|
||||
let result=await submitAndRefresh(async()=>({id:'task-1'}),(t:any)=>{committed=t},async()=>{throw new Error('列表暂不可用')})
|
||||
assert.equal(committed.id,'task-1');assert.equal(result.task.id,'task-1');assert.match(result.refreshError,/列表暂不可用/)
|
||||
})
|
||||
@@ -0,0 +1,75 @@
|
||||
import {test} from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import {build} from 'esbuild'
|
||||
import {fileURLToPath} from 'node:url'
|
||||
|
||||
// api.ts 依赖 Vite 环境;只隔离环境适配层,所有工作流逻辑使用实际源代码与 Vue refs。
|
||||
let bundle = await build({
|
||||
stdin:{contents:"import * as workflow from './src/stores/visit.ts'; export {workflow}; export {reactive} from 'vue'",resolveDir:fileURLToPath(new URL('..',import.meta.url))},
|
||||
bundle:true,platform:'node',format:'esm',write:false,
|
||||
plugins:[{name:'isolate-vite-api',setup(builder){
|
||||
builder.onResolve({filter:/services\/api$/},()=>({path:'vite-api',namespace:'test'}))
|
||||
builder.onLoad({filter:/.*/,namespace:'test'},()=>({contents:"export const storeId='test-store';export function api(){throw new Error('Inject test API')}",loader:'js'}))
|
||||
}}]
|
||||
})
|
||||
let module = await import('data:text/javascript;base64,'+Buffer.from(bundle.outputFiles[0].text).toString('base64'))
|
||||
function fixture(){
|
||||
assert.equal(typeof module.workflow.createVisitWorkflow,'function','visit workflow must expose isolated API dependencies')
|
||||
let identity='user-a', failPost=false, failRefresh=false, posted:any[]=[], current:any=null
|
||||
let send=async(path:string,method='GET',body?:any)=>{
|
||||
if(path==='/visits/current'&&method==='DELETE'){current=null;return null}
|
||||
if(path==='/visits/current')return current
|
||||
if(path==='/visits'&&method==='POST')return current={id:'visit-'+body.tableCode,storeId:'test-store',tableCode:body.tableCode,status:'active'}
|
||||
if(path==='/service-requests'&&method==='POST'){posted.push({...body});if(failPost)throw new Error('ambiguous network');return {id:'task-'+posted.length,userId:identity,tableCode:current.tableCode,storeId:'test-store',status:'pending',...body}}
|
||||
if(failRefresh)throw new Error('refresh offline')
|
||||
return []
|
||||
}
|
||||
let state=module.reactive(module.workflow.createVisitWorkflow({storeId:'test-store',identity:()=>identity,ensure:async()=>{},api:(...args:any[])=>send(args[0],args[1],args[2])}))
|
||||
return {state,posted,getIdentity:()=>identity,setIdentity:(v:string)=>{identity=v},setCurrent:(v:any)=>{current=v},setFailPost:(v:boolean)=>{failPost=v},setFailRefresh:(v:boolean)=>{failRefresh=v},setAPI:(fn:typeof send)=>{send=fn}}
|
||||
}
|
||||
test('模糊失败重试复用UUID;刷新失败后保留成功且禁止再次提交',{timeout:3000},async()=>{
|
||||
let f=fixture(),s=f.state;await s.confirmVisit('T01');s.note='same';f.setFailPost(true)
|
||||
await assert.rejects(s.submit(),/ambiguous/);f.setFailPost(false);f.setFailRefresh(true)
|
||||
let result=await s.submit();assert.deepEqual(f.posted[0],f.posted[1]);assert.match(f.posted[0].requestId,/^[0-9a-f-]{36}$/)
|
||||
assert.match(result.refreshError,/offline/);assert.ok(s.submitted);await s.submit();assert.equal(f.posted.length,2)
|
||||
s.startNewRequest();await s.submit();assert.notEqual(f.posted[1].requestId,f.posted[2].requestId)
|
||||
})
|
||||
test('首入申请去确认到店保留草稿;新到店会话清除旧结果与幂等上下文',{timeout:3000},async()=>{
|
||||
let f=fixture(),s=f.state;s.configureRequest({kind:'print',patternId:'pattern-1'});s.note='keep draft'
|
||||
await s.confirmVisit('T01');assert.equal(s.kind,'print');assert.equal(s.note,'keep draft');assert.equal(s.patternId,'pattern-1')
|
||||
await s.submit();let id=f.posted[0].requestId;await s.leave();assert.equal(s.submitted,null)
|
||||
await s.confirmVisit('T02');assert.equal(s.submitted,null);await s.submit();assert.equal(f.posted.length,2);assert.equal(s.submitted.tableCode,'T02');assert.notEqual(f.posted[1].requestId,id)
|
||||
})
|
||||
test('服务端刷新发现新的visit,也清除旧成功结果',{timeout:3000},async()=>{
|
||||
let f=fixture(),s=f.state;await s.confirmVisit('T01');await s.submit()
|
||||
f.setCurrent({id:'new-visit',storeId:'test-store',tableCode:'T02',status:'active'});await s.refreshVisit();assert.equal(s.submitted,null);assert.equal(s.visit.tableCode,'T02')
|
||||
})
|
||||
test('换账号清除旧结果/草稿/记录,旧账号在途读取不能回写',{timeout:3000},async()=>{
|
||||
let f=fixture(),s=f.state;await s.confirmVisit('T01');s.note='private';await s.submit()
|
||||
let resolve:(v:any)=>void=()=>{};f.setAPI(async()=>new Promise(r=>{resolve=r}))
|
||||
let read=s.refreshTasks();f.setIdentity('user-b');s.configureRequest({})
|
||||
assert.equal(s.submitted,null);assert.equal(s.visit,null);assert.equal(s.note,'');assert.equal(s.tasks.length,0)
|
||||
resolve([{id:'old-user-task'}]);await read;assert.equal(s.tasks.length,0)
|
||||
})
|
||||
test('在途提交期间换账号不写回旧成功结果',{timeout:3000},async()=>{
|
||||
let f=fixture(),s=f.state;await s.confirmVisit('T01')
|
||||
let resolve:(v:any)=>void=()=>{};f.setAPI(async()=>new Promise(r=>{resolve=r}))
|
||||
let posting=s.submit();f.setIdentity('user-b');s.configureRequest({})
|
||||
resolve({id:'old-task',tableCode:'T01'});await assert.rejects(posting,/账号|会话/);assert.equal(s.submitted,null);assert.equal(s.tasks.length,0)
|
||||
})
|
||||
test('首次登录确认桌位保留草稿;登出清除成功与旧记录',{timeout:3000},async()=>{
|
||||
let f=fixture(),s=f.state;f.setIdentity('');s.configureRequest({kind:'ironing'});s.note='first draft'
|
||||
// initial logged-out state is the normal request → visit journey.
|
||||
let initial=module.reactive(module.workflow.createVisitWorkflow({storeId:'test-store',identity:f.getIdentity,ensure:async()=>f.setIdentity('user-a'),api:async(path:string,_method:string,body:any)=>({id:'first',storeId:'test-store',tableCode:body.tableCode,status:'active'})}))
|
||||
initial.configureRequest({kind:'ironing'});initial.note='first draft';await initial.confirmVisit('T01');assert.equal(initial.kind,'ironing');assert.equal(initial.note,'first draft')
|
||||
f.setIdentity('user-a');await s.confirmVisit('T01');await s.submit();f.setIdentity('');await s.refreshVisit();assert.equal(s.submitted,null);assert.equal(s.visit,null);assert.equal(s.tasks.length,0)
|
||||
})
|
||||
|
||||
test('换账号后旧到店读取不回写;未关联打印不发请求',{timeout:3000},async()=>{
|
||||
let f=fixture(),s=f.state;await s.confirmVisit('T01');s.configureRequest({kind:'print'})
|
||||
await assert.rejects(s.submit(),/图纸/);assert.equal(f.posted.length,0)
|
||||
let resolve:(v:any)=>void=()=>{};f.setAPI(async()=>new Promise(r=>{resolve=r}))
|
||||
let read=s.refreshVisit();f.setIdentity('user-b');s.configureRequest({})
|
||||
resolve({id:'old-visit',storeId:'test-store',tableCode:'T01',status:'active'});await read
|
||||
assert.equal(s.visit,null);assert.equal(s.submitted,null)
|
||||
})
|
||||
@@ -0,0 +1 @@
|
||||
{"compilerOptions":{"target":"ES2020","module":"ESNext","moduleResolution":"Bundler","strict":true,"jsx":"preserve","resolveJsonModule":true,"esModuleInterop":true,"allowImportingTsExtensions":true,"types":["@dcloudio/types","vite/client","node"],"lib":["ES2020","DOM"],"skipLibCheck":true,"noEmit":true},"include":["src/**/*.ts","src/**/*.vue","tests/**/*.ts","vite.config.ts"]}
|
||||
@@ -0,0 +1,4 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import uniModule from '@dcloudio/vite-plugin-uni'
|
||||
let uni = (uniModule as unknown as {default?: typeof uniModule}).default || uniModule
|
||||
export default defineConfig({ plugins: [uni()], server: { host: '127.0.0.1', proxy: { '/api': 'http://127.0.0.1:8080' } } })
|
||||
Reference in New Issue
Block a user