chore(管理端): 初始化工程仓库
This commit is contained in:
+16
@@ -0,0 +1,16 @@
|
||||
<script setup lang="ts">
|
||||
import { computed,ref,watch,onUnmounted } from 'vue'
|
||||
import CallNotifications from './components/CallNotifications.vue'
|
||||
import {useCalls} from './notifications/store'
|
||||
import {useRoute,useRouter} from 'vue-router'
|
||||
import {Bell,Grid,Brush,Setting,OfficeBuilding,SwitchButton,Menu} from '@element-plus/icons-vue'
|
||||
import {useAuth} from './auth'
|
||||
let route=useRoute(),router=useRouter(),auth=useAuth(),open=ref(false)
|
||||
let calls=useCalls()
|
||||
watch(()=>route.path!=='/login'&&auth.user?auth.user.storeId+':'+auth.user.id:'',key=>{if(key)calls.start(key);else calls.stop()},{immediate:true})
|
||||
onUnmounted(calls.stop)
|
||||
let nav=[{path:'/tasks',label:'店内服务',icon:Bell},{path:'/patterns',label:'图纸库',icon:Grid},{path:'/palette',label:'色板与物料',icon:Brush},{path:'/tables',label:'桌位管理',icon:OfficeBuilding},{path:'/store',label:'门店设置',icon:Setting}]
|
||||
let title=computed(()=>nav.find(n=>n.path===route.path)?.label||'工作台')
|
||||
function logout(){auth.logout();void router.replace('/login')}
|
||||
</script>
|
||||
<template><RouterView v-if="route.path==='/login'"/><div v-else class="workspace"><a class="skip" href="#main">跳到主要内容</a><aside :class="{open}"><div class="brand"><span class="bead-mark">▦</span><div>豆间<small>PERLER STUDIO</small></div></div><div class="store-badge"><span class="dot"/> 单店工作台<small>{{auth.user?.storeId}}</small></div><nav aria-label="工作台导航"><RouterLink v-for="item in nav.filter(n=>auth.user?.role==='admin'||n.path==='/tasks')" :key="item.path" :to="item.path" @click="open=false"><el-icon><component :is="item.icon"/></el-icon>{{item.label}}</RouterLink></nav><div class="sidebar-note">把每一份创意<br>照顾成完整的作品。</div></aside><section class="body"><header class="topbar"><el-button class="mobile-menu" :icon="Menu" aria-label="展开导航" @click="open=!open"/><span>门店工作台 <span class="separator">/</span> {{title}}</span><div class="account"><span>{{auth.user?.name}}</span><el-button :icon="SwitchButton" text @click="logout">退出</el-button></div></header><main id="main" tabindex="-1"><CallNotifications/><RouterView :key="route.path"/></main><footer>豆间 · 手作有温度,服务有回应 <span>STORE 001</span></footer></section></div></template>
|
||||
@@ -0,0 +1,7 @@
|
||||
import { unwrap, ApiError } from './contract.ts'
|
||||
export async function api<T>(path:string,method='GET',data?:unknown,signal?:AbortSignal):Promise<T>{
|
||||
let token=sessionStorage.getItem('perler-admin-token'); let response=await fetch('/api'+path,{method,headers:{'Content-Type':'application/json',...(token?{Authorization:'Bearer '+token}:{})},body:data===undefined?undefined:JSON.stringify(data),signal});
|
||||
if(!response.ok)throw new Error('服务连接失败,请稍后重试');
|
||||
try{return unwrap<T>(await response.json())}catch(e){if(e instanceof ApiError&&e.code===403){sessionStorage.removeItem('perler-admin-token');window.dispatchEvent(new Event('auth-expired'))}throw e}
|
||||
}
|
||||
export async function downloadPrint(id:string){let response=await fetch('/api/patterns/'+encodeURIComponent(id)+'/print?pitchMm=5',{headers:{Authorization:'Bearer '+(sessionStorage.getItem('perler-admin-token')||'')}});if(!response.ok)throw new Error('下载连接失败');if(response.headers.get('content-type')?.includes('application/json')){unwrap(await response.json());throw new Error('未返回打印文件')}let blob=await response.blob();let url=URL.createObjectURL(blob);let a=document.createElement('a');a.href=url;a.download='pattern-'+id+'.svg';a.click();setTimeout(()=>URL.revokeObjectURL(url),1000)}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import { api } from './api'
|
||||
import type { User } from './contract'
|
||||
export let useAuth=defineStore('auth',()=>{let user=ref<User|null>(null);async function verify(){if(!sessionStorage.getItem('perler-admin-token'))return false; try{let current=await api<User>('/me');if(!['admin','staff'].includes(current.role)||current.storeId!=='store-001'){logout();return false}user.value=current;return true}catch(e){user.value=null;throw e}}function logout(){user.value=null;sessionStorage.removeItem('perler-admin-token')}async function login(username:string,password:string){let result=await api<{token:string;user:User}>('/auth/admin','POST',{username,password});sessionStorage.setItem('perler-admin-token',result.token);await verify()}return{user,verify,logout,login}})
|
||||
@@ -0,0 +1,18 @@
|
||||
<script setup lang="ts">
|
||||
import {computed} from 'vue'
|
||||
import {Bell} from '@element-plus/icons-vue'
|
||||
import {useCalls} from '../notifications/store'
|
||||
let calls=useCalls()
|
||||
let status=computed(()=>({online:'实时连接正常',connecting:'正在连接',retrying:'连接中断,正在重连',closed:'连接已停止'}[calls.connection]))
|
||||
</script>
|
||||
<template>
|
||||
<section class="call-notifications" aria-label="呼叫语音提醒">
|
||||
<div class="call-notification-row">
|
||||
<div class="call-notification-title"><el-icon><Bell/></el-icon><strong>呼叫提醒</strong><span :class="['connection-state',calls.connection]">{{status}}</span></div>
|
||||
<div class="call-notification-actions"><span>{{calls.arming?'正在试听…':calls.enabled?'语音已开启':'语音未开启'}}</span><el-button v-if="calls.enabled" @click="calls.mute">关闭语音</el-button><el-button :type="calls.enabled?'default':'primary'" :loading="calls.arming" :disabled="calls.loading||calls.arming" @click="calls.enable">{{calls.enabled?'试听':'开启语音'}}</el-button></div>
|
||||
</div>
|
||||
<p class="call-notification-hint">新呼叫播报桌位与服务类型,切换工作台页面后仍有效。{{calls.voiceName?'本机音色:'+calls.voiceName+'。':'需要本机中文语音。'}}请保持页面开启;刷新后需重新开启语音。</p>
|
||||
<p v-if="calls.lastSpoken" class="call-last-spoken">最近播报:{{calls.lastSpoken}}</p>
|
||||
<el-alert v-if="calls.voiceError||calls.error" :title="calls.voiceError||('服务同步异常:'+calls.error)" type="warning" :closable="false" role="alert"/>
|
||||
</section>
|
||||
</template>
|
||||
@@ -0,0 +1,9 @@
|
||||
export class ApiError extends Error { code: number; constructor(code:number,msg:string){super(msg);this.code=code} }
|
||||
export function unwrap<T>(body:{code:number;msg:string;data:T}):T { if(body.code!==200) throw new ApiError(body.code,body.msg||'请求失败'); return body.data }
|
||||
export function nextStatus(status:string){return status==='pending'?'accepted':status==='accepted'?'completed':null}
|
||||
export interface User {id:string;name:string;role:string;storeId:string}
|
||||
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[];visibility:string;beadCount:number;demo?:boolean}
|
||||
export interface Task {id:string;tableCode:string;kind:string;note:string;status:string;patternId?:string;createdAt:string;updatedAt:string}
|
||||
export interface Store {id:string;name:string;address:string;hours:string;notice:string;localBaseUrl:string;publicBaseUrl:string}
|
||||
export interface Table {code:string;label:string;enabled:boolean}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import { ElButton, ElIcon, ElInput, ElForm, ElFormItem, ElAlert, ElTable, ElTableColumn, ElSelect, ElOption, ElSwitch, ElSkeleton, ElTag, ElDrawer, ElEmpty } from 'element-plus'
|
||||
import 'element-plus/dist/index.css'
|
||||
import './style.css'
|
||||
import App from './App.vue'
|
||||
import {useAuth} from './auth'
|
||||
let app=createApp(App);app.use(createPinia());let router=createRouter({history:createWebHistory(),routes:[{path:'/login',component:()=>import('./views/Login.vue')},{path:'/',redirect:'/tasks'},{path:'/tasks',component:()=>import('./views/Tasks.vue')},{path:'/patterns',component:()=>import('./views/Patterns.vue'),meta:{admin:true}},{path:'/palette',component:()=>import('./views/Settings.vue'),meta:{admin:true}},{path:'/tables',component:()=>import('./views/Settings.vue'),meta:{admin:true}},{path:'/store',component:()=>import('./views/Settings.vue'),meta:{admin:true}},{path:'/:pathMatch(.*)*',redirect:'/tasks'}]});router.beforeEach(async to=>{let auth=useAuth();if(to.path==='/login')return true;try{if(!await auth.verify())return'/login';if(to.meta.admin&&auth.user?.role!=='admin')return'/tasks'}catch{return'/login'}});window.addEventListener('auth-expired',()=>{useAuth().logout();void router.replace('/login')});app.use(router);for(let component of [ElButton,ElIcon,ElInput,ElForm,ElFormItem,ElAlert,ElTable,ElTableColumn,ElSelect,ElOption,ElSwitch,ElSkeleton,ElTag,ElDrawer,ElEmpty])app.use(component);app.mount('#app')
|
||||
@@ -0,0 +1,51 @@
|
||||
import type { Task } from '../contract.ts'
|
||||
|
||||
export function announcement(task:Task){
|
||||
let kinds:Record<string,string>={help:'呼叫员工',refill:'需要补充豆料',ironing:'需要熨烫作品',print:'需要打印图纸'}
|
||||
return `${task.tableCode.slice(0,40)} 桌,${kinds[task.kind]||'需要服务'}。`
|
||||
}
|
||||
export function selectVoice<T extends {lang:string;localService:boolean}>(voices:T[]):T|undefined{
|
||||
let local=voices.filter(v=>v.localService&&/^zh(?:[-_]|$)/i.test(v.lang))
|
||||
return local.find(v=>/^zh[-_]CN$/i.test(v.lang))||local[0]
|
||||
}
|
||||
export class CallTracker {
|
||||
private seen=new Set<string>()
|
||||
private initialized=false
|
||||
accept(tasks:Task[]){
|
||||
let fresh=tasks.filter(t=>!this.seen.has(t.id)&&t.status==='pending')
|
||||
for(let task of tasks)this.seen.add(task.id)
|
||||
if(!this.initialized){this.initialized=true;return []}
|
||||
return fresh.sort((a,b)=>Date.parse(a.createdAt)-Date.parse(b.createdAt))
|
||||
}
|
||||
reset(){this.seen.clear();this.initialized=false}
|
||||
}
|
||||
export interface SpeechDriver {speak(text:string,done:(error?:string)=>void):void;cancel():void}
|
||||
interface SpeechJob {id?:string;text:string;done?:()=>void}
|
||||
export class SpeechQueue {
|
||||
private driver:SpeechDriver
|
||||
private onError:(error:string)=>void
|
||||
private waiting:SpeechJob[]=[]
|
||||
private current:SpeechJob|undefined
|
||||
private generation=0
|
||||
constructor(driver:SpeechDriver,onError:(error:string)=>void){this.driver=driver;this.onError=onError}
|
||||
add(tasks:Task[]){
|
||||
if(this.waiting.length+tasks.length>32){this.stop();this.onError('待播报呼叫较多,请先查看服务队列,再重新开启语音。');return}
|
||||
this.waiting.push(...tasks.map(t=>({id:t.id,text:announcement(t)})));this.next()
|
||||
}
|
||||
preview(text:string,done:()=>void){this.waiting.push({text,done});this.next()}
|
||||
retain(ids:Set<string>){
|
||||
this.waiting=this.waiting.filter(t=>!t.id||ids.has(t.id))
|
||||
if(this.current?.id&&!ids.has(this.current.id)){this.generation++;this.current=undefined;this.driver.cancel();this.next()}
|
||||
}
|
||||
stop(){this.generation++;this.waiting=[];this.current=undefined;this.driver.cancel()}
|
||||
private next(){
|
||||
if(this.current||!this.waiting.length)return
|
||||
this.current=this.waiting.shift();let job=this.current!,generation=this.generation
|
||||
try{this.driver.speak(job.text,error=>{
|
||||
if(generation!==this.generation)return
|
||||
this.current=undefined
|
||||
if(error){this.stop();this.onError(error);return}
|
||||
job.done?.();this.next()
|
||||
})}catch{this.stop();this.onError('语音播放失败,请检查浏览器后重新开启。')}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
export type ConnectionState='connecting'|'online'|'retrying'|'closed'
|
||||
interface SocketLike {
|
||||
onopen:((event:any)=>void)|null;onmessage:((event:any)=>void)|null;onclose:((event:any)=>void)|null;onerror:((event:any)=>void)|null
|
||||
send(data:string):void;close():void
|
||||
}
|
||||
export interface LinkOptions {
|
||||
socket:()=>SocketLike;token:()=>string;state:(state:ConnectionState)=>void;sync:()=>void;expired:()=>void
|
||||
later?:(fn:()=>void,ms:number)=>ReturnType<typeof setTimeout>;clear?:(timer:ReturnType<typeof setTimeout>)=>void
|
||||
}
|
||||
export class RealtimeLink {
|
||||
private options:LinkOptions
|
||||
private socket:SocketLike|undefined
|
||||
private timer:ReturnType<typeof setTimeout>|undefined
|
||||
private watchdog:ReturnType<typeof setTimeout>|undefined
|
||||
private stopped=true
|
||||
private attempts=0
|
||||
constructor(options:LinkOptions){this.options=options}
|
||||
private later(fn:()=>void,ms:number){return (this.options.later||setTimeout)(fn,ms)}
|
||||
private clear(timer:ReturnType<typeof setTimeout>|undefined){if(timer!==undefined)(this.options.clear||clearTimeout)(timer)}
|
||||
start(){this.stop();this.stopped=false;this.attempts=0;this.connect()}
|
||||
stop(){this.stopped=true;this.clear(this.timer);this.clear(this.watchdog);this.detach();this.options.state('closed')}
|
||||
private detach(){if(this.socket){let socket=this.socket;this.socket=undefined;socket.onopen=null;socket.onmessage=null;socket.onclose=null;socket.onerror=null;socket.close()}}
|
||||
private connect(){
|
||||
if(this.stopped)return
|
||||
this.options.state(this.attempts?'retrying':'connecting')
|
||||
let socket:SocketLike
|
||||
try{socket=this.options.socket();this.socket=socket}catch{this.retry();return}
|
||||
let ready=false
|
||||
let arm=(ms:number)=>{this.clear(this.watchdog);this.watchdog=this.later(()=>{this.detach();this.retry()},ms)}
|
||||
arm(10000)
|
||||
socket.onopen=()=>socket.send(JSON.stringify({type:'auth',token:this.options.token()}))
|
||||
socket.onmessage=event=>{
|
||||
let data:any;try{data=JSON.parse(event.data)}catch{return}
|
||||
if(data.type==='ready'){ready=true;this.attempts=0;this.options.state('online');arm(45000);this.options.sync()}
|
||||
else if(ready&&data.type==='heartbeat')arm(45000)
|
||||
else if(ready&&data.type==='tasks.changed'){arm(45000);this.options.sync()}
|
||||
}
|
||||
socket.onerror=()=>socket.close()
|
||||
socket.onclose=event=>{
|
||||
this.clear(this.watchdog);this.detach()
|
||||
if(event.code===4403||event.code===4401){this.stop();this.options.expired();return}
|
||||
this.retry()
|
||||
}
|
||||
}
|
||||
private retry(){if(this.stopped)return;this.options.state('retrying');this.clear(this.watchdog);this.clear(this.timer);let delay=Math.min(30000,1000*2**Math.min(this.attempts++,5));this.timer=this.later(()=>this.connect(),delay)}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import {selectVoice,type SpeechDriver} from './core'
|
||||
|
||||
// 显式选择本机语音;浏览器提供的远程音色不自动启用。
|
||||
export function browserSpeech(onStart:(text:string)=>void):SpeechDriver {
|
||||
let timer:ReturnType<typeof setTimeout>|undefined
|
||||
let active:SpeechSynthesisUtterance|undefined
|
||||
function cancel(){clearTimeout(timer);if(active){active.onend=null;active.onerror=null;active.onstart=null;active=undefined}window.speechSynthesis?.cancel()}
|
||||
return {cancel,speak(text,done){
|
||||
let synth=window.speechSynthesis
|
||||
let voice=synth&&selectVoice(synth.getVoices())
|
||||
if(!voice){done('没有可用的本机中文语音,请在操作系统安装中文语音后重新开启。');return}
|
||||
let utterance=new SpeechSynthesisUtterance(text);active=utterance
|
||||
utterance.voice=voice;utterance.lang=voice.lang;utterance.rate=1;utterance.volume=1
|
||||
let finished=false
|
||||
function finish(error?:string){if(finished)return;finished=true;clearTimeout(timer);utterance.onend=null;utterance.onerror=null;utterance.onstart=null;active=undefined;done(error)}
|
||||
utterance.onstart=()=>onStart(text)
|
||||
utterance.onend=()=>finish()
|
||||
utterance.onerror=e=>finish(e.error==='not-allowed'?'浏览器阻止了播放,请点击「开启语音」重试。':'语音播放失败,请检查系统语音与声音输出后重试。')
|
||||
timer=setTimeout(()=>{finish('语音播放超时,请检查声音输出后重新开启。');synth.cancel()},20000)
|
||||
synth.speak(utterance)
|
||||
}}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import {defineStore} from 'pinia'
|
||||
import {ref} from 'vue'
|
||||
import {api} from '../api'
|
||||
import type {Task} from '../contract'
|
||||
import {CallTracker,SpeechQueue,selectVoice} from './core'
|
||||
import {browserSpeech} from './speech'
|
||||
import {RealtimeLink,type ConnectionState} from './realtime'
|
||||
|
||||
export let useCalls=defineStore('calls',()=>{
|
||||
let tasks=ref<Task[]>([]),loading=ref(true),error=ref(''),last=ref('')
|
||||
let connection=ref<ConnectionState>('closed'),enabled=ref(false),arming=ref(false),voiceError=ref(''),lastSpoken=ref(''),voiceName=ref('')
|
||||
let supported=ref(false),tracker=new CallTracker(),scope='',generation=0,dirty=false
|
||||
let controller:AbortController|undefined,pending:Promise<void>|undefined,timer:ReturnType<typeof setInterval>|undefined
|
||||
let driver=browserSpeech(text=>{lastSpoken.value=text})
|
||||
let queue=new SpeechQueue(driver,message=>{enabled.value=false;arming.value=false;voiceError.value=message})
|
||||
let link=new RealtimeLink({socket(){let url=new URL('/api/ws',location.href);url.protocol=location.protocol==='https:'?'wss:':'ws:';return new WebSocket(url)},token:()=>sessionStorage.getItem('perler-admin-token')||'',state:value=>{connection.value=value},sync:()=>{void refresh()},expired:()=>window.dispatchEvent(new Event('auth-expired'))})
|
||||
function voices(){supported.value='speechSynthesis' in window&&'SpeechSynthesisUtterance' in window;voiceName.value=supported.value?selectVoice(window.speechSynthesis.getVoices())?.name||'':''}
|
||||
function mute(){enabled.value=false;arming.value=false;queue.stop()}
|
||||
function enable(){
|
||||
voices();voiceError.value=''
|
||||
if(!supported.value){voiceError.value='当前浏览器不支持语音,请使用支持语音合成的桌面浏览器。';return}
|
||||
if(!voiceName.value){voiceError.value='没有可用的本机中文语音,请在操作系统安装中文语音后重试。';return}
|
||||
arming.value=true;enabled.value=true;let epoch=generation
|
||||
// 直接在点击事件中启动试听,满足浏览器对用户操作的要求。
|
||||
queue.preview('门店语音提醒已开启。',()=>{if(epoch===generation)arming.value=false})
|
||||
}
|
||||
async function refresh():Promise<void>{
|
||||
if(!scope)return
|
||||
if(pending){dirty=true;return pending}
|
||||
let epoch=generation,abort=new AbortController();controller=abort
|
||||
let deadline=setTimeout(()=>abort.abort(),12000)
|
||||
pending=(async()=>{try{
|
||||
let result=await api<Task[]>('/service-requests','GET',undefined,abort.signal)
|
||||
if(epoch!==generation)return
|
||||
tasks.value=result;error.value='';last.value=new Date().toLocaleTimeString('zh-CN')
|
||||
let fresh=tracker.accept(result)
|
||||
queue.retain(new Set(result.filter(t=>t.status==='pending').map(t=>t.id)))
|
||||
if(enabled.value)queue.add(fresh)
|
||||
}catch(e){if(epoch===generation)error.value=e instanceof DOMException&&e.name==='AbortError'?'同步超时,正在等待重试':e instanceof Error?e.message:'同步失败'}finally{
|
||||
clearTimeout(deadline)
|
||||
if(epoch===generation){controller=undefined;pending=undefined;loading.value=false;if(dirty){dirty=false;void refresh()}}
|
||||
}})()
|
||||
return pending
|
||||
}
|
||||
function start(key:string){
|
||||
if(key===scope)return
|
||||
stop();scope=key;voices();window.speechSynthesis?.addEventListener('voiceschanged',voices)
|
||||
void refresh();link.start()
|
||||
// WS 是主路径;低频补查覆盖断线、漏通知和代理异常。
|
||||
timer=setInterval(()=>{void refresh()},60000)
|
||||
window.addEventListener('online',recover);document.addEventListener('visibilitychange',visible)
|
||||
}
|
||||
function recover(){if(!scope)return;if(connection.value!=='online')link.start();void refresh()}
|
||||
function visible(){if(!document.hidden)recover()}
|
||||
function stop(){
|
||||
scope='';generation++;link.stop();mute();controller?.abort();controller=undefined;pending=undefined;dirty=false
|
||||
clearInterval(timer);window.removeEventListener('online',recover);document.removeEventListener('visibilitychange',visible)
|
||||
window.speechSynthesis?.removeEventListener('voiceschanged',voices)
|
||||
tracker.reset();tasks.value=[];loading.value=true;error.value='';last.value='';lastSpoken.value='';voiceError.value=''
|
||||
}
|
||||
return {tasks,loading,error,last,connection,enabled,arming,voiceError,lastSpoken,voiceName,supported,refresh,start,stop,enable,mute}
|
||||
})
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import {ref} from 'vue';import {useRouter} from 'vue-router';import {useAuth} from '../auth'
|
||||
let username=ref(''),password=ref(''),error=ref(''),busy=ref(false),router=useRouter(),auth=useAuth()
|
||||
async function submit(){error.value='';if(!username.value||!password.value){error.value='请输入账号和密码';return}busy.value=true;try{await auth.login(username.value,password.value);password.value='';await router.replace('/tasks')}catch(e){error.value=e instanceof Error?e.message:'登录失败'}finally{busy.value=false}}
|
||||
</script>
|
||||
<template><main class="login"><section class="login-story"><div class="brand">▦ 豆间 <small>PERLER STUDIO</small></div><div class="pixel-flower" aria-hidden="true">▦</div><h1>小小一颗豆,<br>大大的创作日常。</h1><p>门店的每一次呼叫、每一份图纸,<br>都在这里有条不紊。</p><small>门店工作台 / STORE OPERATIONS</small></section><section class="login-form"><span class="eyebrow">WELCOME BACK</span><h2>开始今天的手作时光</h2><p class="muted">使用门店配置的管理员账号登录</p><el-form label-position="top" @submit.prevent="submit"><el-form-item label="管理员账号"><el-input v-model="username" autocomplete="username" placeholder="请输入账号" aria-label="管理员账号"/></el-form-item><el-form-item label="登录密码"><el-input v-model="password" type="password" show-password autocomplete="current-password" placeholder="请输入密码" aria-label="登录密码"/></el-form-item><el-alert v-if="error" :title="error" type="error" :closable="false" role="alert"/><el-button type="primary" native-type="submit" :loading="busy" class="login-submit">进入工作台</el-button></el-form><small class="muted">账号由服务端环境配置。网络连接不授予门店权限。</small></section></main></template>
|
||||
@@ -0,0 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import {ref,computed,onMounted} from 'vue';import {ElMessage} from 'element-plus';import {api,downloadPrint} from '../api';import type {Pattern} from '../contract';import {useAuth} from '../auth'
|
||||
let patterns=ref<Pattern[]>([]),query=ref(''),loading=ref(true),error=ref(''),drawer=ref(false),busy=ref(false),selected=ref<Pattern|null>(null),title=ref(''),category=ref(''),visibility=ref('private'),json=ref(''),auth=useAuth()
|
||||
let filtered=computed(()=>patterns.value.filter(p=>(p.title+' '+p.category).includes(query.value)))
|
||||
async function load(){loading.value=true;try{patterns.value=await api<Pattern[]>('/admin/patterns');error.value=''}catch(e){error.value=e instanceof Error?e.message:'加载失败'}finally{loading.value=false}}function edit(p:Pattern|null){selected.value=p;title.value=p?.title||'';category.value=p?.category||'';visibility.value=p?.visibility||'private';json.value='';drawer.value=true}
|
||||
async function save(){busy.value=true;try{if(selected.value)await api('/admin/patterns/'+encodeURIComponent(selected.value.id),'PATCH',{title:title.value,category:category.value,visibility:visibility.value});else{let data=JSON.parse(json.value);if(!data||typeof data!=='object'||Array.isArray(data))throw new Error('请输入一个图纸 JSON 对象');await api('/admin/patterns','POST',{...data,title:title.value||data.title,category:category.value||data.category,visibility:visibility.value,storeId:auth.user?.storeId})}drawer.value=false;await load();ElMessage.success('图纸已保存')}catch(e){ElMessage.error(e instanceof Error?e.message:'保存失败')}finally{busy.value=false}}async function print(id:string){try{await downloadPrint(id)}catch(e){ElMessage.error(e instanceof Error?e.message:'下载失败')}}onMounted(load)
|
||||
</script>
|
||||
<template><div class="page-heading"><div><span class="eyebrow">PATTERN LIBRARY</span><h1>图纸库</h1><p>管理门店图纸,让创意随时开始。</p></div><el-button type="primary" @click="edit(null)">导入图纸</el-button></div><section class="panel"><div class="panel-toolbar"><h2>全部图纸 <small>{{patterns.length}} 份</small></h2><el-input v-model="query" placeholder="搜索名称或分类" clearable aria-label="搜索图纸" class="search"/></div><el-alert v-if="error" :title="error" type="error" :closable="false"/><el-skeleton v-if="loading" :rows="5" animated/><el-table v-else :data="filtered" empty-text="还没有图纸,可导入已有色号网格"><el-table-column prop="title" label="图纸名称" min-width="200"><template #default="{row}"><strong>{{row.title}}</strong><el-tag v-if="row.demo" size="small" type="warning">演示</el-tag></template></el-table-column><el-table-column prop="category" label="分类" min-width="120"/><el-table-column label="尺寸 / 用豆量" min-width="150"><template #default="{row}">{{row.width}} × {{row.height}} / {{row.beadCount}} 豆</template></el-table-column><el-table-column label="发布状态" width="110"><template #default="{row}"><el-tag :type="row.visibility==='public'?'success':'info'">{{row.visibility==='public'?'已发布':'已下架'}}</el-tag></template></el-table-column><el-table-column label="操作" width="180"><template #default="{row}"><el-button size="small" @click="edit(row)">编辑</el-button><el-button size="small" @click="print(row.id)">打印</el-button></template></el-table-column></el-table><div class="table-footer">图纸色号须与门店维护的色板一致;演示图纸不代表正式商品授权。</div></section><el-drawer v-model="drawer" :title="selected?'编辑图纸':'导入图纸 JSON'" size="min(100%, 34rem)"><el-form label-position="top" @submit.prevent="save"><el-form-item label="图纸名称"><el-input v-model="title" aria-label="图纸名称"/></el-form-item><el-form-item label="分类"><el-input v-model="category" aria-label="图纸分类"/></el-form-item><el-form-item label="发布状态"><el-select v-model="visibility" aria-label="发布状态"><el-option label="下架 / 仅授权访问" value="private"/><el-option label="发布到顾客图纸库" value="public"/></el-select></el-form-item><template v-if="!selected"><p class="muted">JSON 包含 width、height、cells、colors。透明格用 null;colors 含 code、name、hex、location、available。</p><el-form-item label="图纸 JSON"><el-input v-model="json" type="textarea" :rows="12" aria-label="图纸 JSON" placeholder='{"title":"小花","category":"植物","width":1,"height":1,"cells":["DEMO-01"],"colors":[…]}'/></el-form-item></template><el-button type="primary" native-type="submit" :loading="busy">保存图纸</el-button></el-form></el-drawer></template>
|
||||
@@ -0,0 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import {ref,computed,onMounted} from 'vue';import {useRoute} from 'vue-router';import {ElMessage} from 'element-plus';import {api} from '../api';import {useAuth} from '../auth';import type {Color,Store,Table} from '../contract'
|
||||
let route=useRoute(),auth=useAuth(),mode=computed(()=>route.path.slice(1)),colors=ref<Color[]>([]),tables=ref<Table[]>([]),store=ref<Store|null>(null),loading=ref(true),error=ref(''),drawer=ref(false),busy=ref(false),editingIndex=ref(-1),color=ref<Color>({code:'',name:'',hex:'#526448',location:'',available:true}),table=ref<Table>({code:'',label:'',enabled:true}),storeDraft=ref<Store|null>(null)
|
||||
let titles:Record<string,string>={palette:'色板与物料',tables:'桌位管理',store:'门店设置'}
|
||||
async function load(){loading.value=true;try{let id=encodeURIComponent(auth.user!.storeId);if(mode.value==='palette')colors.value=await api<Color[]>('/palettes?storeId='+id);else if(mode.value==='tables')tables.value=await api<Table[]>('/tables?storeId='+id);else store.value=(await api<Store[]>('/stores')).find(s=>s.id===auth.user!.storeId)||null;error.value=''}catch(e){error.value=e instanceof Error?e.message:'加载失败'}finally{loading.value=false}}
|
||||
function edit(index=-1){editingIndex.value=index;if(mode.value==='palette')color.value=index<0?{code:'',name:'',hex:'#526448',location:'',available:true}:{...colors.value[index]!};if(mode.value==='tables')table.value=index<0?{code:'',label:'',enabled:true}:{...tables.value[index]!};if(mode.value==='store')storeDraft.value=store.value?{...store.value}:{id:auth.user!.storeId,name:'',address:'',hours:'',notice:'',localBaseUrl:'',publicBaseUrl:''};drawer.value=true}
|
||||
async function save(){busy.value=true;try{let storeId=auth.user!.storeId;if(mode.value==='palette'){if(!color.value.code||!color.value.name||!/^#[a-f\d]{6}$/i.test(color.value.hex))throw new Error('请填写唯一色号、名称和六位十六进制色值');let next=colors.value.map(c=>({...c}));if(editingIndex.value<0)next.push({...color.value});else next[editingIndex.value]={...color.value};if(new Set(next.map(c=>c.code)).size!==next.length)throw new Error('色号不能重复');await api('/admin/palette','PUT',{storeId,colors:next})}else if(mode.value==='tables'){if(!table.value.code||!table.value.label)throw new Error('请填写桌码与名称');let next=tables.value.map(t=>({...t}));if(editingIndex.value<0)next.push({...table.value});else next[editingIndex.value]={...table.value};if(new Set(next.map(t=>t.code)).size!==next.length)throw new Error('桌码不能重复');await api('/admin/tables','PUT',{storeId,tables:next})}else await api('/admin/store','PUT',storeDraft.value);drawer.value=false;await load();ElMessage.success('设置已保存')}catch(e){ElMessage.error(e instanceof Error?e.message:'保存失败')}finally{busy.value=false}}onMounted(load)
|
||||
</script>
|
||||
<template><div class="page-heading"><div><span class="eyebrow">STUDIO MANAGEMENT</span><h1>{{titles[mode]}}</h1><p>{{mode==='palette'?'每种颜色,都有自己的位置。':mode==='tables'?'维护可扫码到店的有效桌位。':'管理顾客可见的门店信息与服务地址。'}}</p></div><el-button type="primary" :disabled="loading||!!error" @click="edit()">{{mode==='store'?'编辑门店':mode==='palette'?'添加颜色':'添加桌位'}}</el-button></div><section class="panel"><el-alert v-if="error" :title="error" type="error" :closable="false"/><el-skeleton v-if="loading" :rows="5" animated/><template v-else><el-table v-if="mode==='palette'" :data="colors" empty-text="尚未维护色板,请添加正式色号"><el-table-column label="颜色" width="80"><template #default="{row}"><span class="swatch" :style="{background:row.hex}" :aria-label="row.hex"/></template></el-table-column><el-table-column prop="code" label="色号" min-width="120"/><el-table-column prop="name" label="颜色名称" min-width="150"/><el-table-column prop="hex" label="HEX" width="110"/><el-table-column prop="location" label="物料位置" min-width="150"/><el-table-column label="可用状态" width="120"><template #default="{row}">{{row.available?'可用':'暂停供应'}}</template></el-table-column><el-table-column label="操作" width="100"><template #default="{$index}"><el-button size="small" @click="edit($index)">编辑</el-button></template></el-table-column></el-table><el-table v-else-if="mode==='tables'" :data="tables" empty-text="没有桌位,请添加"><el-table-column prop="code" label="到店桌码" min-width="150"/><el-table-column prop="label" label="桌位名称" min-width="150"/><el-table-column label="状态" width="120"><template #default="{row}"><el-tag :type="row.enabled?'success':'info'">{{row.enabled?'已启用':'已停用'}}</el-tag></template></el-table-column><el-table-column label="操作" width="100"><template #default="{$index}"><el-button size="small" @click="edit($index)">编辑</el-button></template></el-table-column></el-table><dl v-else-if="store" class="store-details"><template v-for="(label,key) in {name:'门店名称',address:'门店地址',hours:'营业时间',notice:'门店公告',localBaseUrl:'店内服务地址',publicBaseUrl:'公网服务地址'}" :key="key"><dt>{{label}}</dt><dd>{{store[key]||'尚未配置'}}</dd></template><dt>授权范围</dt><dd>{{store.id}} · 当前账号仅管理本店</dd></dl><el-empty v-else description="未找到当前门店"/></template><div class="table-footer">配置以服务端校验与保存结果为准。停用桌位会阻止新的到店与服务呼叫。</div></section><el-drawer v-model="drawer" :title="'编辑'+titles[mode]" size="min(100%, 32rem)"><el-form label-position="top" @submit.prevent="save"><template v-if="mode==='palette'"><el-form-item label="色号"><el-input v-model="color.code" aria-label="色号" :disabled="editingIndex>=0"/></el-form-item><el-form-item label="颜色名称"><el-input v-model="color.name" aria-label="颜色名称"/></el-form-item><el-form-item label="HEX 色值"><el-input v-model="color.hex" aria-label="HEX 色值"/></el-form-item><el-form-item label="物料位置"><el-input v-model="color.location" placeholder="例如 A 柜 · 第 2 层" aria-label="物料位置"/></el-form-item><el-form-item label="可用状态"><el-switch v-model="color.available" aria-label="该颜色是否可用"/></el-form-item><p class="muted">修改已有色值前,请核对已保存图纸;演示 DEMO 色号须替换为正式库存。</p></template><template v-else-if="mode==='tables'"><el-form-item label="桌码"><el-input v-model="table.code" :disabled="editingIndex>=0" aria-label="桌码"/></el-form-item><el-form-item label="桌位名称"><el-input v-model="table.label" aria-label="桌位名称"/></el-form-item><el-form-item label="启用桌位"><el-switch v-model="table.enabled" aria-label="启用桌位"/></el-form-item></template><template v-else-if="storeDraft"><el-form-item v-for="(label,key) in {name:'门店名称',address:'门店地址',hours:'营业时间',notice:'门店公告',localBaseUrl:'店内 HTTPS 服务地址',publicBaseUrl:'公网 HTTPS 服务地址'}" :key="key" :label="label"><el-input v-model="storeDraft[key]" :aria-label="label" :type="key==='notice'?'textarea':'text'"/></el-form-item><p class="muted">地址可留空;配置时必须使用完整 HTTPS 地址。顾客端不会读取 Wi-Fi 名称。</p></template><el-button type="primary" native-type="submit" :loading="busy">保存设置</el-button></el-form></el-drawer></template>
|
||||
@@ -0,0 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import {ref,computed} from 'vue';import {storeToRefs} from 'pinia';import {useCalls} from '../notifications/store';import {Refresh} from '@element-plus/icons-vue';import {ElMessage} from 'element-plus';import {api,downloadPrint} from '../api';import {nextStatus,type Task} from '../contract'
|
||||
let calls=useCalls();let {tasks,loading,error,last}=storeToRefs(calls);let load=calls.refresh
|
||||
let status=ref('active'),kind=ref(''),query=ref(''),updating=ref('')
|
||||
let labels:Record<string,string>={help:'现场帮助',refill:'补充豆料',ironing:'熨烫作品',print:'图纸打印'},statuses:Record<string,string>={pending:'待接单',accepted:'处理中',completed:'已完成',cancelled:'已取消'}
|
||||
let counts=computed(()=>['pending','accepted','completed'].map(s=>tasks.value.filter(t=>t.status===s).length));let filtered=computed(()=>tasks.value.filter(t=>(status.value==='all'||(status.value==='active'?['pending','accepted'].includes(t.status):t.status===status.value))&&(!kind.value||t.kind===kind.value)&&(!query.value||`${t.tableCode} ${t.note}`.includes(query.value))).sort((a,b)=>Date.parse(b.createdAt)-Date.parse(a.createdAt)))
|
||||
async function update(t:Task){let next=nextStatus(t.status);if(!next)return;updating.value=t.id;try{await api('/service-requests/'+encodeURIComponent(t.id),'PATCH',{status:next});await load();ElMessage.success(next==='accepted'?'已接单,请前往桌位':'服务已完成')}catch(e){ElMessage.error(e instanceof Error?e.message:'操作失败')}finally{updating.value=''}}
|
||||
async function print(id:string){try{await downloadPrint(id)}catch(e){ElMessage.error(e instanceof Error?e.message:'下载失败')}}
|
||||
</script>
|
||||
<template><div class="page-heading"><div><span class="eyebrow">TODAY IN THE STUDIO</span><h1>店内服务</h1><p>让每一次呼叫,都得到及时回应。</p></div><el-button :icon="Refresh" :loading="loading" @click="load">刷新列表</el-button></div><div class="summary-strip"><div v-for="(label,i) in ['等待接单','正在处理','累计完成']" :key="label"><span>{{label}}</span><strong>{{counts[i]}}</strong><small>{{i===0?'优先处理顾客呼叫':i===1?'服务正在进行中':'当前列表中的已完成任务'}}</small></div></div><section class="panel"><div class="panel-toolbar"><h2>服务队列 <small>{{filtered.length}} 条</small></h2><div class="sync"><span>{{calls.connection==='online'?'WebSocket 实时同步':'等待实时连接 · 每分钟补查'}}</span></div></div><div class="filters"><el-select v-model="status" aria-label="按任务状态筛选"><el-option label="待处理与处理中" value="active"/><el-option label="全部状态" value="all"/><el-option v-for="(label,value) in statuses" :key="value" :label="label" :value="value"/></el-select><el-select v-model="kind" clearable placeholder="全部服务类型" aria-label="按服务类型筛选"><el-option v-for="(label,value) in labels" :key="value" :label="label" :value="value"/></el-select><el-input v-model="query" clearable placeholder="搜索桌码、备注" aria-label="搜索桌码和备注"/></div><el-alert v-if="error" :title="error" type="error" :closable="false" role="alert"/><el-skeleton v-if="loading" :rows="5" animated/><el-table v-else :data="filtered" empty-text="当前没有符合条件的服务任务" class="task-table"><el-table-column label="桌位" width="100"><template #default="{row}"><strong class="table-code">{{row.tableCode}}</strong></template></el-table-column><el-table-column label="服务类型" min-width="130"><template #default="{row}">{{labels[row.kind]||row.kind}}</template></el-table-column><el-table-column prop="note" label="顾客备注" min-width="220" show-overflow-tooltip/><el-table-column label="状态" width="120"><template #default="{row}"><span class="status" :class="row.status">{{statuses[row.status]}}</span></template></el-table-column><el-table-column label="呼叫时间" min-width="160"><template #default="{row}">{{new Date(row.createdAt).toLocaleString('zh-CN')}}</template></el-table-column><el-table-column label="操作" width="205" fixed="right"><template #default="{row}"><el-button v-if="nextStatus(row.status)" type="primary" size="small" :loading="updating===row.id" :disabled="!!updating" @click="update(row)">{{row.status==='pending'?'接单':'完成服务'}}</el-button><el-button v-if="row.patternId" size="small" @click="print(row.patternId)">打印文件</el-button><span v-if="!nextStatus(row.status)&&!row.patternId" class="muted">已结束</span></template></el-table-column></el-table><div class="table-footer" aria-live="polite">{{last?'上次同步 '+last:'等待首次同步'}}<span>打印为 SVG 文件下载,由店员手动打印。</span></div></section><div class="notice"><strong>支付与储值尚未开放</strong><span>首期专注门店手作服务,交易与充值功能不可操作。</span><el-button disabled>支付未启用</el-button><el-button disabled>储值未启用</el-button></div></template>
|
||||
Reference in New Issue
Block a user