tomoya преди 1 година
родител
ревизия
f903b72198
променени са 59 файла, в които са добавени 1402 реда и са изтрити 229 реда
  1. 1 0
      backend/app/.gitignore
  2. 2 1
      backend/app/app/api/api_v1/api.py
  3. 27 31
      backend/app/app/api/api_v1/endpoints/images.py
  4. 36 8
      backend/app/app/api/api_v1/endpoints/login.py
  5. 2 2
      backend/app/app/api/api_v1/endpoints/reputations.py
  6. 41 0
      backend/app/app/api/api_v1/endpoints/ser_no.py
  7. 105 12
      backend/app/app/api/api_v1/endpoints/videos.py
  8. 12 0
      backend/app/app/api/deps.py
  9. 1 1
      backend/app/app/core/celery_app.py
  10. 1 0
      backend/app/app/core/celeryconf.py
  11. 2 1
      backend/app/app/crud/__init__.py
  12. 1 1
      backend/app/app/crud/crud_article.py
  13. 25 0
      backend/app/app/crud/crud_ser_no.py
  14. 3 3
      backend/app/app/crud/crud_video.py
  15. 2 0
      backend/app/app/db/base.py
  16. 3 1
      backend/app/app/models/__init__.py
  17. 14 0
      backend/app/app/models/serial_number.py
  18. 1 1
      backend/app/app/models/user.py
  19. 1 1
      backend/app/app/models/video.py
  20. 10 0
      backend/app/app/models/voice.py
  21. 1 0
      backend/app/app/schemas/__init__.py
  22. 21 0
      backend/app/app/schemas/serial_number.py
  23. 1 0
      backend/app/app/schemas/user.py
  24. 2 2
      backend/app/app/schemas/video.py
  25. 46 34
      backend/app/app/worker.py
  26. 12 9
      frontend/src/App.vue
  27. 46 18
      frontend/src/api.ts
  28. BIN
      frontend/src/assets/img/qrcode/Angela.png
  29. BIN
      frontend/src/assets/img/qrcode/aianchor3-title.png
  30. BIN
      frontend/src/assets/img/qrcode/aianchor3bg.png
  31. BIN
      frontend/src/assets/img/qrcode/angela.webp
  32. BIN
      frontend/src/assets/img/qrcode/button1.png
  33. BIN
      frontend/src/assets/img/qrcode/button2.png
  34. BIN
      frontend/src/assets/img/qrcode/button3.png
  35. BIN
      frontend/src/assets/img/qrcode/choozmologo.png
  36. BIN
      frontend/src/assets/img/qrcode/icon-19.png
  37. BIN
      frontend/src/assets/img/qrcode/icon-20.png
  38. BIN
      frontend/src/assets/img/qrcode/line1.png
  39. BIN
      frontend/src/assets/img/qrcode/line2.png
  40. BIN
      frontend/src/assets/img/qrcode/moichiu.png
  41. BIN
      frontend/src/assets/img/qrcode/moichiu.webp
  42. BIN
      frontend/src/assets/img/qrcode/startline.png
  43. BIN
      frontend/src/assets/img/qrcode/startline1.png
  44. BIN
      frontend/src/assets/img/qrcode/儲值卷-06.png
  45. BIN
      frontend/src/assets/img/qrcode/儲值卷-07.png
  46. BIN
      frontend/src/assets/img/qrcode/儲值卷-13.png
  47. 14 4
      frontend/src/components/Dialog.vue
  48. 1 0
      frontend/src/components/Navbar.vue
  49. 1 1
      frontend/src/env.ts
  50. 3 1
      frontend/src/interfaces/index.ts
  51. 2 0
      frontend/src/language/en.json
  52. 2 0
      frontend/src/language/zh.json
  53. 12 7
      frontend/src/router/index.ts
  54. 99 43
      frontend/src/stores/main.ts
  55. 1 0
      frontend/src/views/Login.vue
  56. 766 0
      frontend/src/views/Qrcode.vue
  57. 9 3
      frontend/src/views/main/Dashboard.vue
  58. 32 7
      frontend/src/views/main/Image.vue
  59. 41 37
      frontend/src/views/main/Start.vue

+ 1 - 0
backend/app/.gitignore

@@ -1,3 +1,4 @@
 .mypy_cache
 .coverage
 htmlcov
+app/worker.py

+ 2 - 1
backend/app/app/api/api_v1/api.py

@@ -1,6 +1,6 @@
 from fastapi import APIRouter
 
-from app.api.api_v1.endpoints import  login, users, utils, videos, images, reputations
+from app.api.api_v1.endpoints import  login, users, utils, videos, images, reputations, ser_no
 
 api_router = APIRouter()
 api_router.include_router(login.router, tags=["login"])
@@ -9,3 +9,4 @@ api_router.include_router(utils.router, prefix="/utils", tags=["utils"])
 api_router.include_router(videos.router, prefix="/videos", tags=["videos"])
 api_router.include_router(images.router, prefix="/images", tags=["iamges"])
 api_router.include_router(reputations.router, prefix="/reputations", tags=["reputations"])
+api_router.include_router(ser_no.router, prefix="/ser_nos", tags=["serial numbers"])

+ 27 - 31
backend/app/app/api/api_v1/endpoints/images.py

@@ -26,7 +26,7 @@ LOCAL_ZIP_STORAGE = Path("/").joinpath(settings.LOCAL_ZIP_STORAGE)
 
 
 router = APIRouter()
-
+sr_clients = {}
 
 @router.post("/sr")
 def supser_resolution(
@@ -55,42 +55,35 @@ def supser_resolution(
           return {"error": str(e)}
       finally:
           upload_files[i].file.close()
-    
+          
     background_tasks.add_task(wait_finish, new_dir, filenames)
 
     print(filenames)
-    return JSONResponse({"filenames": stemnames}, background=background_tasks)
+    return JSONResponse({"filenames": filenames}, background=background_tasks)
 
 async def wait_finish(dirname, filenames):
-    # print("start: "+str(datetime.now()))
-    new_dir_path = Path(BACKEND_ZIP_STORAGE).joinpath(dirname)
     process = await asyncio.create_subprocess_exec("sshpass", "-p", "choozmo9", 
-                    "scp", "-P", "5722", "-o", "StrictHostKeyChecking=no", "-r", f"{str(new_dir_path)}", f"root@172.104.93.163:{str(LOCAL_ZIP_STORAGE)}")
+                     "scp", "-P", "5722", "-o", "StrictHostKeyChecking=no", "-r", f"{str(BACKEND_ZIP_STORAGE/dirname)}", f"root@172.104.93.163:{str(LOCAL_ZIP_STORAGE)}")
     await process.wait()
     # r = subprocess.run(["sshpass", "-p", "choozmo9", 
     #                 "scp", "-P", "5722", "-o", "StrictHostKeyChecking=no", "-r", f"{str(new_dir_path)}", f"root@172.104.93.163:{str(LOCAL_ZIP_STORAGE)}"])
-    
-    restored_imgs = "restored_imgs" 
-    res = celery_app.send_task("app.worker.super_resolution", args=[dirname])
-    # print(res.state)
+    res = celery_app.send_task("app.worker.super_resolution", args=[dirname, filenames])
+
     while True:
-        await asyncio.sleep(0.5)
-        print(res.state)
-        if res.status == 'SUCCESS':
-            print("recieve finished")
-            break
+       await asyncio.sleep(0.5)
+       if res.state == "SUCCESS":
+           break
+    restored_imgs = "restored_imgs"
+    process = await asyncio.create_subprocess_exec("sshpass", "-p", "choozmo9", 
+                     "scp", "-P", "5722", "-o", "StrictHostKeyChecking=no", "-r", f"root@172.104.93.163:{str(LOCAL_ZIP_STORAGE/dirname/restored_imgs)}/*", f"{str(BACKEND_ZIP_STORAGE)}")
+    await process.wait()
     for filename in filenames:
-        process = await asyncio.create_subprocess_exec("sshpass", "-p", "choozmo9", 
-                      "scp", "-P", "5722", "-o", "StrictHostKeyChecking=no", "-r", f"root@172.104.93.163:{str(LOCAL_ZIP_STORAGE/dirname/restored_imgs/filename)}", f"{str(BACKEND_ZIP_STORAGE)}")
-        await process.wait()
-        # re = subprocess.run(["sshpass", "-p", "choozmo9", 
-        #             "scp", "-P", "5722", "-o", "StrictHostKeyChecking=no", "-r", f"root@172.104.93.163:{str(LOCAL_ZIP_STORAGE/dirname/restored_imgs/filename)}", f"{str(BACKEND_ZIP_STORAGE)}"])
-        
-        for sr_client in sr_clients.values():
-            await sr_client.send_text(f"{filename}")
-    # print("end: "+str(datetime.now()))
+        await publish(filename)
 
-    
+
+async def publish(data):
+    for sr_client in sr_clients.values():
+        await sr_client.send_text(f"{data}")
 
 @router.get("/sr")
 def get_image(
@@ -103,12 +96,17 @@ def get_image(
     """
     Download image
     """
+    return_files = list(BACKEND_ZIP_STORAGE.glob(stored_file_name))
+    if return_files:
+        return_file = return_files[0]
+        return FileResponse(path=str(return_file), filename=file_name+"_hr"+return_file.suffix)
+    else:
+        print("non")
     
-    return_file_path = list(BACKEND_ZIP_STORAGE.glob(stored_file_name+"*"))[0]
-    return FileResponse(path=str(return_file_path), media_type='image/png', filename=file_name+return_file_path.suffix)
 
-ws_clients = {}
-sr_clients = {}
+@router.delete("/sr")
+def del_image():
+    pass
 
 @router.websocket("/sr")
 async def websocket_endpoint(websocket: WebSocket):
@@ -122,11 +120,9 @@ async def websocket_endpoint(websocket: WebSocket):
             print(f"{key}:{data}")
             if data.startswith("unsubscribe"):
               del sr_clients[key]
-              print(f"beybey: {key}")
               #for client in sr_clients.values():
               #      await client.send_text(f"ID: {key} | Message: {data}")
 
     except:
-        #await websocket.close()
         # 接続が切れた場合、当該クライアントを削除する
         del sr_clients[key]

+ 36 - 8
backend/app/app/api/api_v1/endpoints/login.py

@@ -1,10 +1,10 @@
 from datetime import timedelta
-from typing import Any
+from typing import Any, Optional
 
 from fastapi import APIRouter, Body, Depends, HTTPException
 from fastapi.security import OAuth2PasswordRequestForm
 from sqlalchemy.orm import Session
-
+from datetime import datetime
 from app import crud, models, schemas
 from app.api import deps
 from app.core import security
@@ -22,9 +22,9 @@ from google.auth.transport import requests
 router = APIRouter()
 
 
-@router.post("/login/access-token", response_model=schemas.Token)
+@router.post("/login/access-token")#, response_model=schemas.Token)
 def login_access_token(
-    db: Session = Depends(deps.get_db), form_data: OAuth2PasswordRequestForm = Depends()
+    db: Session = Depends(deps.get_db), form_data: OAuth2PasswordRequestForm = Depends(), add_time_code: Optional[str] = None
 ) -> Any:
     """
     OAuth2 compatible token login, get an access token for future requests
@@ -37,16 +37,30 @@ def login_access_token(
     elif not crud.user.is_active(user):
         raise HTTPException(status_code=400, detail="Inactive user")
     access_token_expires = timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
-    return {
+    return_msg = {
         "access_token": security.create_access_token(
             user.id, expires_delta=access_token_expires
         ),
         "token_type": "bearer",
     }
+    if add_time_code:
+        available_ser_no = crud.serial_number.available(db, ser_no=add_time_code)
+        print(available_ser_no)
+        if available_ser_no:
+            user_in = schemas.UserUpdate(available_time=user.available_time+available_ser_no.time)
+            crud.user.update(db, db_obj=user, obj_in=user_in)
+        
+            ser_no_in = schemas.SerialNumberUpdate(code=available_ser_no.code, is_used=True, used_datetime=str(datetime.now()), owner_id=user.id)
+            crud.serial_number.update(db, db_obj=available_ser_no, obj_in=ser_no_in)
+            print(available_ser_no.time, type(available_ser_no.time))
+            return_msg['time_added'] = available_ser_no.time
+        else:
+            return_msg['time_added'] = -1
+    return return_msg 
 
-@router.post("/login/google/access-token", response_model=schemas.Token)
+@router.post("/login/google/access-token")#, response_model=schemas.Token)
 def login_access_token(
-    db: Session = Depends(deps.get_db), form_data: OAuth2PasswordRequestForm = Depends()
+    db: Session = Depends(deps.get_db), form_data: OAuth2PasswordRequestForm = Depends(), add_time_code: Optional[str] = None
 ) -> Any:
     """
     OAuth2 compatible token login, get an access token for future requests
@@ -60,12 +74,26 @@ def login_access_token(
     elif not crud.user.is_active(user):
         raise HTTPException(status_code=400, detail="Inactive user")
     access_token_expires = timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
-    return {
+    return_msg = {
         "access_token": security.create_access_token(
             user.id, expires_delta=access_token_expires
         ),
         "token_type": "bearer",
     }
+    if add_time_code:
+        available_ser_no = crud.serial_number.available(db, ser_no=add_time_code)
+        print(available_ser_no)
+        if available_ser_no:
+            user_in = schemas.UserUpdate(available_time=user.available_time+available_ser_no.time)
+            crud.user.update(db, db_obj=user, obj_in=user_in)
+        
+            ser_no_in = schemas.SerialNumberUpdate(code=available_ser_no.code, is_used=True, used_datetime=str(datetime.now()), owner_id=user.id)
+            crud.serial_number.update(db, db_obj=available_ser_no, obj_in=ser_no_in)
+            print(available_ser_no.time, type(available_ser_no.time))
+            return_msg['time_added'] = available_ser_no.time
+        else:
+            return_msg['time_added'] = -1
+    return return_msg 
 
 
 

+ 2 - 2
backend/app/app/api/api_v1/endpoints/reputations.py

@@ -36,6 +36,6 @@ async def post_reputation(
     """
     
     #print(posted_article)
-    article = crud.artivle.create_with_owner(db=db, obj_in=posted_article, owner_id=current_user.id, posted_datetime=str(datetime.now()))
+    article = crud.article.create_with_owner(db=db, obj_in=posted_article, owner_id=current_user.id, posted_datetime=str(datetime.now()))
     if article:
-      return {"id":article.id}
+        return {"id":article.id}

+ 41 - 0
backend/app/app/api/api_v1/endpoints/ser_no.py

@@ -0,0 +1,41 @@
+from typing import Any, List
+from fastapi.responses import RedirectResponse
+from fastapi import APIRouter, Body, Depends, HTTPException, Request
+from fastapi.encoders import jsonable_encoder
+from pydantic.networks import EmailStr
+from sqlalchemy.orm import Session
+
+from app import crud, models, schemas
+from app.api import deps
+from app.core.config import settings
+
+from datetime import datetime
+
+router = APIRouter()
+
+@router.get("/add-time")
+def add_time(
+    *,
+    db: Session = Depends(deps.get_db),
+    current_user: models.User = Depends(deps.get_current_active_user),
+    ser_no: models.SerialNumber = Depends(deps.get_avairable_serial_number)
+)-> Any:
+
+  
+    user_in = schemas.UserUpdate(available_time=current_user.available_time+ser_no.time)
+    crud.user.update(db, db_obj=current_user, obj_in=user_in)
+    
+    ser_no_in = schemas.SerialNumberUpdate(code=ser_no.code, is_used=True, used_datetime=str(datetime.now()), owner_id=current_user.id)
+    crud.serial_number.update(db, db_obj=ser_no, obj_in=ser_no_in)
+
+    return {"time_added": ser_no.time}
+
+@router.get("/add-time-no-token")
+def add_time_no_token(
+    request: Request,
+    ser_no: models.SerialNumber = Depends(deps.get_avairable_serial_number)
+)-> Any:
+    print(f"{request.base_url}login?ser_no={ser_no.code}")
+    #return RedirectResponse(f"{request.base_url}login?ser_no={ser_no.code}")
+    return RedirectResponse(f"http://localhost:5173/login?add-time-code={ser_no.code}")
+

+ 105 - 12
backend/app/app/api/api_v1/endpoints/videos.py

@@ -1,10 +1,10 @@
 from typing import Any, List, Optional
 import subprocess
 from fastapi import UploadFile, File, Form
-from fastapi.responses import FileResponse
-from fastapi import APIRouter, Depends, HTTPException
+from fastapi.responses import FileResponse, JSONResponse
+from fastapi import APIRouter, Depends, HTTPException, BackgroundTasks, WebSocket
 from sqlalchemy.orm import Session
-
+from fastapi.encoders import jsonable_encoder
 import app.crud as crud
 import app.models as models
 import app.schemas as schemas 
@@ -13,14 +13,16 @@ from app.api import deps
 from app.core.celery_app import celery_app
 from app.core.config import settings
 from pathlib import Path
+from app.db.session import SessionLocal
 
-from app.core.celery_app import celery_app
+import asyncio
 
 BACKEND_ZIP_STORAGE = Path("/app").joinpath(settings.BACKEND_ZIP_STORAGE)
 LOCAL_ZIP_STORAGE = Path("/").joinpath(settings.LOCAL_ZIP_STORAGE)
 
 
 router = APIRouter()
+video_clients = {}
 
 @router.get("/", response_model=List[schemas.Video])
 def get_video_list(
@@ -40,6 +42,20 @@ def get_video_list(
         )
     return videos
 
+@router.post("/test")
+def test(
+    *,
+    title: str,
+    anchor_id: int,
+    lang_id: int,
+    current_user: models.User = Depends(deps.get_current_active_user),
+) -> Any:
+    video_data = {"title":title, "anchor_id":anchor_id, "lang_id":lang_id}
+    print(video_data)
+    task = celery_app.send_task("app.worker.make_video_test",  kwargs=video_data, )
+    print(task)
+    return "ok"
+
 @router.post("/", response_model=schemas.Video)
 def upload_plot(
     *,
@@ -49,16 +65,17 @@ def upload_plot(
     lang_id: int=Form(...),
     upload_file: UploadFile=File(),
     current_user: models.User = Depends(deps.get_current_active_user),
+    background_tasks: BackgroundTasks,
 ) -> Any:
     """
     Create new video.
     """
     print(title)
     print(upload_file.filename)
-    file_name = crud.video.generate_file_name(db=db, n=20)
+    filename = crud.video.generate_file_name(db=db, n=20)
     
     try:
-        with open(str(Path(BACKEND_ZIP_STORAGE).joinpath(video.stored_file_name+".zip")), 'wb') as f:
+        with open(str(Path(BACKEND_ZIP_STORAGE).joinpath(filename+".zip")), 'wb') as f:
             while contents := upload_file.file.read(1024 * 1024):
                 f.write(contents)
     except Exception as e:
@@ -66,18 +83,76 @@ def upload_plot(
         return {"error": str(e)}
     finally:
         upload_file.file.close()
+    
 
-    # check valid file
-    video_create = schemas.VideoCreate(title=title, progress_state="waiting", stored_file_name=file_name)
-    video = crud.video.create_with_owner(db=db, obj_in=video_create, owner_id=current_user.id)
-
+    '''
     zip_filename = video.stored_file_name+".zip"
     print(str(BACKEND_ZIP_STORAGE/zip_filename))
     r = subprocess.run(["sshpass", "-p", "choozmo9", 
                     "scp", "-P", "5722", "-o", "StrictHostKeyChecking=no", f"{str(BACKEND_ZIP_STORAGE/zip_filename)}", f"root@172.104.93.163:{str(LOCAL_ZIP_STORAGE/zip_filename)}"])
     print(r.returncode)
-    celery_app.send_task("app.worker.make_video", args=[video.id, video.stored_file_name, current_user.id])
-    return video
+    celery_app.send_task("app.worker.make_video", args=[video.id, video.stored_file_name, current_user.id, anchor_id, current_user.membership_status, current_user.available_time])
+    '''
+    video_create = schemas.VideoCreate(title=title, progress_state="PENDING", stored_filename=filename)
+    video = crud.video.create_with_owner(db=db, obj_in=video_create, owner_id=current_user.id)
+    return_msg = {"video_message":"accepted"}
+    video_data = jsonable_encoder(video)
+    video_data['membership_status'] = current_user.membership_status
+    video_data['available_time'] = current_user.available_time
+    video_data['video_id'] = video_data['id']
+    background_tasks.add_task(wait_finish, video_data)
+    return  JSONResponse(return_msg, background=background_tasks)
+
+async def wait_finish(video_data:dict): 
+    zip_filename = video_data['stored_filename']+".zip"
+    process = await asyncio.create_subprocess_exec("sshpass", "-p", "choozmo9", 
+                    "scp", "-P", "5722", "-o", "StrictHostKeyChecking=no", f"{str(BACKEND_ZIP_STORAGE/zip_filename)}", f"root@172.104.93.163:{str(LOCAL_ZIP_STORAGE)}")
+    await process.wait()
+
+    task = celery_app.send_task("app.worker.make_video", kwargs=video_data)
+    while True:
+       await asyncio.sleep(1)
+       if task.state != "PENDING":
+           break
+       
+    db = SessionLocal()
+    video = db.query(models.Video).get(video_data['id'])
+    video.progress_state = "STARTED" 
+    db.commit()
+    db.close()
+    msg_data = f"{video_data['stored_filename']}:STARTED"
+    await publish(msg_data)
+
+    while True:
+        await asyncio.sleep(1)
+        if task.state != "STARTED":
+            break
+
+    if task.state == "SUCCESS":
+        db = SessionLocal()
+        video = db.query(models.Video).get(video_data['id'])
+        user = db.query(models.User).get(video_data['owner_id'])
+        video.progress_state = "SUCCESS" 
+        if time := task.result:
+            user.available_time -= int(time)
+            video.length = int(time)
+        db.commit()
+        db.close()
+        msg_data = f"{video_data['stored_filename']}:SUCCESS:{int(time)}"
+
+    elif task.state == "FAILURE":
+        db = SessionLocal()
+        video = db.query(models.Video).get(video_data['id'])
+        video.progress_state = "FAILURE" 
+        db.commit()
+        db.close()
+        msg_data = f"{video_data['stored_filename']}:FAILURE"
+
+    await publish(msg_data)
+
+async def publish(data):
+    for video_client in video_clients.values():
+        await video_client.send_text(f"{data}")
 
 @router.get("/{id}")
 def download_video(
@@ -89,3 +164,21 @@ def download_video(
     
     return {"message":"address"}
 
+@router.websocket("")
+async def websocket_endpoint(websocket: WebSocket):
+    await websocket.accept()
+    key = websocket.headers.get('sec-websocket-key')
+    video_clients[key] = websocket
+    try:
+        while True:
+            data = await websocket.receive_text()
+            if not data.startswith("subscribe"):
+              del video_clients[key]
+              #for client in sr_clients.values():
+              #      await client.send_text(f"ID: {key} | Message: {data}")
+
+    except:
+        # 接続が切れた場合、当該クライアントを削除する
+        del video_clients[key]
+
+

+ 12 - 0
backend/app/app/api/deps.py

@@ -59,3 +59,15 @@ def get_current_active_superuser(
             status_code=400, detail="The user doesn't have enough privileges"
         )
     return current_user
+
+def get_valid_serial_number(code: str, db: Session = Depends(get_db)):
+    ser_no = db.query(models.SerialNumber).filter(models.SerialNumber.code==code).first()
+    if not ser_no:
+        raise HTTPException(status_code=400, detail="This serial number is invalid")
+    
+    return ser_no
+
+def get_avairable_serial_number(ser_no:models.SerialNumber = Depends(get_valid_serial_number)):
+    if ser_no.is_used:
+        raise HTTPException(status_code=400, detail="This serial number is already used")
+    return ser_no

+ 1 - 1
backend/app/app/core/celery_app.py

@@ -4,4 +4,4 @@ celery_app = Celery("worker", broker="redis://172.104.93.163:16379/0", backend="
 
 
 
-celery_app.conf.task_routes = {"app.worker.make_video": "main-queue", "app.worker.super_resolution": "main-queue"}
+celery_app.conf.task_routes = {"app.worker.make_video": "video", "app.worker.super_resolution": "image", "app.worker.make_video_test": "video"}

+ 1 - 0
backend/app/app/core/celeryconf.py

@@ -0,0 +1 @@
+task_track_started = True

+ 2 - 1
backend/app/app/crud/__init__.py

@@ -1,6 +1,7 @@
 from .crud_user import user
 from .crud_video import video
-from .crud_article import artivle
+from .crud_article import article
+from .crud_ser_no import serial_number
 # For a new basic set of CRUD operations you could just do
 
 # from .base import CRUDBase

+ 1 - 1
backend/app/app/crud/crud_article.py

@@ -20,4 +20,4 @@ class CRUDArticle(CRUDBase[Article, ArticleCreate, ArticleUpdate]):
         db.refresh(db_obj)
         return db_obj
     
-artivle = CRUDArticle(Article)
+article = CRUDArticle(Article)

+ 25 - 0
backend/app/app/crud/crud_ser_no.py

@@ -0,0 +1,25 @@
+from typing import List, Optional
+
+from fastapi.encoders import jsonable_encoder
+from sqlalchemy.orm import Session
+
+from app.crud.base import CRUDBase
+from app.models.serial_number import SerialNumber
+from app.schemas.serial_number import SerialNumberCreate, SerialNumberUpdate
+
+from app.utils import random_name
+
+class CRUDSerialNumber(CRUDBase[SerialNumber, SerialNumberCreate, SerialNumberUpdate]):
+    
+    def valid(self, db: Session, *, ser_no:str) -> Optional[SerialNumber]:
+        return db.query(SerialNumber).filter(SerialNumber.code==ser_no).first()
+
+    def available(self, db: Session, *, ser_no:str) -> Optional[SerialNumber]:
+        valid_ser_no = self.valid(db, ser_no=ser_no)
+        if valid_ser_no and not valid_ser_no.is_used:
+            return valid_ser_no
+        else:
+            return None
+        
+    
+serial_number = CRUDSerialNumber(SerialNumber)

+ 3 - 3
backend/app/app/crud/crud_video.py

@@ -35,9 +35,9 @@ class CRUDVideo(CRUDBase[Video, VideoCreate, VideoUpdate]):
       self, db: Session, *, n:int
     ) -> bool:
         while True:
-            file_name = random_name(n)
-            if not db.query(self.model).filter(Video.stored_file_name==file_name).first():
-              return file_name
+            filename = random_name(n)
+            if not db.query(self.model).filter(Video.stored_filename==filename).first():
+              return filename
 
 
 video = CRUDVideo(Video)

+ 2 - 0
backend/app/app/db/base.py

@@ -6,3 +6,5 @@ from app.models.video import Video
 from app.models.enum import Progress, Membership
 from app.models.character import Character
 from app.models.article import Article
+from app.models.serial_number import SerialNumber
+from app.models.voice import Voice

+ 3 - 1
backend/app/app/models/__init__.py

@@ -1,4 +1,6 @@
 from .user import User
 from .video import Video
 from .enum import Membership, Progress
-from .article import Article
+from .article import Article
+from .serial_number import SerialNumber
+from .voice import Voice

+ 14 - 0
backend/app/app/models/serial_number.py

@@ -0,0 +1,14 @@
+from typing import TYPE_CHECKING
+
+from sqlalchemy import Column, ForeignKey, Integer, String, DateTime, Boolean
+
+from app.db.base_class import Base
+
+class SerialNumber(Base):
+  __tablename__ = "serial_number"
+  code = Column(String(length=20), primary_key=True)
+  time = Column(Integer, nullable=False)
+  created_datetime = Column(DateTime)
+  is_used = Column(Boolean, nullable=False, default=False)
+  used_datetime = Column(DateTime)
+  owner_id = Column(Integer, ForeignKey("user.id"))

+ 1 - 1
backend/app/app/models/user.py

@@ -11,7 +11,7 @@ if TYPE_CHECKING:
 class User(Base):
   id = Column(Integer, primary_key=True, index=True)
   full_name = Column(String(20), index=True)
-  email = Column(String(30), unique=True, index=True, nullable=False)
+  email = Column(String(50), unique=True, index=True, nullable=False)
   hashed_password = Column(String(100), nullable=False)
   membership_status = Column(String(10), 
                       ForeignKey("membership.status", onupdate="CASCADE", ondelete="RESTRICT"), 

+ 1 - 1
backend/app/app/models/video.py

@@ -13,7 +13,7 @@ if TYPE_CHECKING:
 class Video(Base):
   id = Column(Integer, primary_key=True, index=True)
   title = Column(String(20), index=True, nullable=False)
-  stored_file_name = Column(String(30), unique=True, nullable=False)
+  stored_filename = Column(String(30), unique=True, nullable=False)
   progress_state = Column(String(10), 
                     ForeignKey("progress.state", ondelete="RESTRICT", onupdate="CASCADE"),
                     default="waiting")

+ 10 - 0
backend/app/app/models/voice.py

@@ -0,0 +1,10 @@
+from typing import TYPE_CHECKING
+
+from sqlalchemy import Column, ForeignKey, Integer, String, DateTime, Boolean
+
+from app.db.base_class import Base
+
+class Voice(Base):
+  id = Column(Integer, primary_key=True, index=True,)
+  name = Column(String(30))
+  play_id = Column(String(30))

+ 1 - 0
backend/app/app/schemas/__init__.py

@@ -3,3 +3,4 @@ from .user import User, UserCreate, UserInDB, UserUpdate
 from .msg import Msg
 from .video import Video, VideoCreate, VideoInDB, VideoUpdate
 from .article import ArticleBase, ArticleCreate, ArticleUpdate
+from .serial_number import SerialNumberBase, SerialNumberCreate, SerialNumberUpdate

+ 21 - 0
backend/app/app/schemas/serial_number.py

@@ -0,0 +1,21 @@
+from typing import Optional
+
+from pydantic import BaseModel
+
+
+
+class SerialNumberBase(BaseModel):
+    code: Optional[str] = None
+    time: Optional[int] = None
+    created_datetime: Optional[str] = None
+    is_used: Optional[bool] = False
+    used_datetime: Optional[str] = None
+
+# Properties to receive via API on creation
+class SerialNumberCreate(SerialNumberBase):
+    pass
+
+
+# Properties to receive via API on update
+class SerialNumberUpdate(SerialNumberBase):
+    owner_id: int

+ 1 - 0
backend/app/app/schemas/user.py

@@ -19,6 +19,7 @@ class UserCreate(UserBase):
 # Properties to receive via API on update
 class UserUpdate(UserBase):
     password: Optional[str] = None
+    available_time: Optional[int] = None
 
 
 class UserInDBBase(UserBase):

+ 2 - 2
backend/app/app/schemas/video.py

@@ -8,13 +8,13 @@ from fastapi import UploadFile, File
 class VideoBase(BaseModel):
     title: Optional[str] = None
     progress_state: Optional[str] = None
-    stored_file_name: Optional[str] = None
+    stored_filename: Optional[str] = None
 
 # Properties to receive on video creation
 class VideoCreate(VideoBase):
     title: str
     progress_state: str
-    stored_file_name: str
+    stored_filename: str
 
 # Properties to receive on video update
 class VideoUpdate(VideoBase):

+ 46 - 34
backend/app/app/worker.py

@@ -6,6 +6,7 @@ import requests
 from pathlib import Path
 from urllib.parse import urlparse, urljoin
 from .aianchor import  make_video_from_zip
+import gc
 #client_sentry = Client(settings.SENTRY_DSN)
 import dataset
 from app.db.session import SessionLocal
@@ -21,11 +22,18 @@ CELERY_VIDEO_STORAGE = Path("/").joinpath(settings.LOCAL_VIDEO_STORAGE)
 LOCAL_VIDEO_STORAGE = Path("/").joinpath(settings.LOCAL_VIDEO_STORAGE)
 
 STORAGE_IP = '192.168.192.252'#os.getenv('STORAGE_IP')
+BACKEND_IP = '172.'
 if not STORAGE_IP:
     raise Exception
 
 @celery_app.task(acks_late=True, bind=True, track_started=True)
-def make_video(self, video_id, filename, user_id) -> str:
+def make_video_test(self, title=None, anchor_id=None, lang_id=None)->int:
+   print(title, anchor_id, lang_id)
+   return 0
+
+@celery_app.task(acks_late=True, bind=True, track_started=True)
+def make_video(self, *, video_id, stored_filename=None, user_id=None, anchor_id=None, membership=None, available_time=None, **others) -> int:
+    
     #video_id, zip_filename, user_id = args
     # download 
     '''
@@ -35,51 +43,55 @@ def make_video(self, video_id, filename, user_id) -> str:
         for chunk in r.iter_content(chunk_size=1024):
             f.write(chunk)
     '''
-    db = SessionLocal()
-    db.execute("SELECT 1")
-    zip_filename = filename + ".zip"
+    # db = SessionLocal()
+    # db.execute("SELECT 1")
+    zip_filename = stored_filename + ".zip"
     r = subprocess.run(["sshpass", "-p", "choozmo9", 
                         "scp", "-o", "StrictHostKeyChecking=no", f"root@{STORAGE_IP}:{str(LOCAL_ZIP_STORAGE/zip_filename)}", f"{str(CELERY_ZIP_STORAGE/zip_filename)}"])
     print(f'get from local storage: {r.returncode}')
-    print(f"video_id: {video_id}, file name: {filename}")
-    db.execute(f"UPDATE video SET progress_state='processing' where id={video_id}")
-    db.commit()
+    print(f"video_id: {video_id}, file name: {stored_filename}")
+    
+    # db.execute(f"UPDATE video SET progress_state='processing' where id={video_id}")
+    # db.commit()
     # make video
+    watermark_path='medias/logo_watermark.jpg'
+    content_time = 0
     try:
-      content_time = make_video_from_zip(working_dir=CELERY_ZIP_STORAGE,style=Path("app/style/choozmo"),
+      if membership=="infinite":
+         watermark_path=None
+      content_time = make_video_from_zip(working_dir=CELERY_ZIP_STORAGE,style=Path("app/style/choozmo"),  
                                          inputfile=zip_filename,
                                          opening=False, 
-                                         ending=False,
-                                         watermark_path='medias/logo_watermark.jpg')
+                                         ending=False, 
+                                         watermark_path=watermark_path, 
+                                         available_time=available_time)
     except Exception as e:
-        print(f"error:{e}")
-        db.execute(f"UPDATE video SET progress_state='failed' where id={video_id}")
-        db.commit()
+      print(f"error:{e}")
+      # db.execute(f"UPDATE video SET progress_state='failed' where id={video_id}")
+      # db.commit()
     else:
-        # 
-        video_filename = filename + ".mp4"
-        r = subprocess.run(["sshpass", "-p", "choozmo9", 
-                            "scp", "-o", "StrictHostKeyChecking=no", f"{str(CELERY_ZIP_STORAGE/'output.mp4')}", f"root@{STORAGE_IP}:{'/var/www/videos/'+video_filename}"])
-        print(f"return to local storage: {r.returncode}")
-        print(f"video_id: {video_id}, file name: {filename}")
+      # 
+      video_filename = stored_filename + ".mp4"
+      r = subprocess.run(["sshpass", "-p", "choozmo9", 
+                          "scp", "-o", "StrictHostKeyChecking=no", f"{str(CELERY_ZIP_STORAGE/'output.mp4')}", f"root@{STORAGE_IP}:{'/var/www/videos/'+video_filename}"])
+      print(f"return to local storage: {r.returncode}")
+      print(f"video_id: {video_id}, file name: {stored_filename}")
 
-        db.execute(f"UPDATE video SET progress_state='completed' where id={video_id}")
-        db.commit()
+      # db.execute(f"UPDATE video SET progress_state='completed', length={int(content_time)} where id={video_id}")
+      # db.commit()
 
-        gc_ret = gc.collect()
-        print(f"gc_collected: {gc_ret}")
-        
-        return str(int(content_time))
+      
+      gc_num = gc.collect()
+      print(f"gc_num: {gc_num}")
+      return int(content_time)
 
 @celery_app.task(acks_late=True, bind=True, track_started=True)
-def super_resolution(self, dirname:str):
-    print(dirname)
-    re = subprocess.run(["sshpass", "-p", "choozmo9", 
+def super_resolution(self, dirname, filenames):
+   
+    r = subprocess.run(["sshpass", "-p", "choozmo9", 
                         "scp", "-o", "StrictHostKeyChecking=no", "-r", f"root@{STORAGE_IP}:{str(LOCAL_ZIP_STORAGE/dirname)}", f"{str(CELERY_ZIP_STORAGE)}"])
-    result_dir = dirname+"_result"
-    re = subprocess.run(["python", "/root/github/GFPGAN/inference_gfpgan.py", "-i", f"{str(CELERY_ZIP_STORAGE/dirname)}", "-o", f"{str(CELERY_ZIP_STORAGE/dirname)}", "-v", "1.4"])
-
-    re = subprocess.run(["sshpass", "-p", "choozmo9", 
+   
+    r = subprocess.run(["python", "/root/github/GFPGAN/inference_gfpgan.py", "-i", f"{str(CELERY_ZIP_STORAGE/dirname)}", "-o", f"{str(CELERY_ZIP_STORAGE/dirname)}", "-v", "1.4", "-s", "2"])
+   
+    r = subprocess.run(["sshpass", "-p", "choozmo9", 
                         "scp", "-o", "StrictHostKeyChecking=no", "-r", f"{str(CELERY_ZIP_STORAGE/dirname)}", f"root@{STORAGE_IP}:{str(LOCAL_ZIP_STORAGE)}"])
-    
-    return "complete"

+ 12 - 9
frontend/src/App.vue

@@ -1,11 +1,3 @@
-<template>
-  <v-app>
-    <LoadingView v-if="loggedIn === null" />
-    <RouterView v-else />
-    <NotificationsManager />
-  </v-app>
-</template>
-
 <script setup lang="ts">
 import { RouterView } from "vue-router";
 import { useMainStore } from "@/stores/main";
@@ -22,10 +14,21 @@ const loggedIn = mainStoreRef.readIsLoggedIn;
 
 //lifecycle
 onMounted(() => {
-  mainStore.checkLoggedIn();
+  let path = location.pathname;
+  if (path !== "/qrcode") {
+    mainStore.checkLoggedIn();
+  }
 });
 </script>
 
+<template>
+  <v-app>
+    <LoadingView v-if="loggedIn === null" />
+    <RouterView v-else />
+    <NotificationsManager />
+  </v-app>
+</template>
+
 <style lang="scss">
 :root {
   --main-color: #ea5413;

+ 46 - 18
frontend/src/api.ts

@@ -18,6 +18,29 @@ export const api = {
 
     return axios.post(`${apiUrl}/api/v1/login/access-token`, params);
   },
+  async qrLogInGetToken(username: string, password: string,ser_no: string) {
+    const params = new URLSearchParams();
+    params.append("username", username);
+    params.append("password", password);
+
+    return axios.post(`${apiUrl}/api/v1/login/access-token?add_time_code=${ser_no}`, params);
+  },
+  async googleLogin(username: string) {
+    const params = new URLSearchParams();
+    params.append("username", username);
+    params.append("password", "google");
+
+    return axios.post(`${apiUrl}/api/v1/login/google/access-token`, params);
+  },
+  async qrGoogleLogin(username: string,ser_no: string) {
+    const params = new URLSearchParams();
+    params.append("username", username);
+    params.append("password", "google");
+    return axios.post(`${apiUrl}/api/v1/login/google/access-token?add_time_code=${ser_no}`, params);
+  },
+  async qrAddTime(token: string, code: string) {
+    return axios.get(`${apiUrl}/api/v1/ser_nos/add-time?code=${code}`, authHeaders(token));
+  },
   async getMe(token: string) {
     return axios.get<IUserProfile>(`${apiUrl}/api/v1/users/me`, authHeaders(token));
   },
@@ -73,8 +96,27 @@ export const api = {
     }
     return axios.post<{ filenames: string[] }>(`${apiUrl}/api/v1/images/sr`, formData, authHeaders(token));
   },
-  async getImage(token: string, data: ImageDownload) {
-    axios({
+  // async getImage(token: string, data: ImageDownload) {
+  //   axios({
+  //     url: `${apiUrl}/api/v1/images/sr?stored_file_name=${data.stored_file_name}&file_name=${data.file_name}`,
+  //     method: 'GET',
+  //     responseType: 'blob',
+  //     headers: {
+  //       'Authorization': `Bearer ${token}`
+  //     },
+  //   }).then((response) => {
+  //     const href = URL.createObjectURL(response.data);
+  //     const link = document.createElement('a');
+  //     link.href = href;
+  //     link.setAttribute('download', `${data.file_name}_hr.png`);
+  //     document.body.appendChild(link);
+  //     link.click();
+  //     document.body.removeChild(link);
+  //     URL.revokeObjectURL(href);
+  //   });
+  // },
+  async downloadImage(token: string, data: ImageDownload) {
+    return axios({
       url: `${apiUrl}/api/v1/images/sr?stored_file_name=${data.stored_file_name}&file_name=${data.file_name}`,
       method: 'GET',
       responseType: 'blob',
@@ -82,14 +124,7 @@ export const api = {
         'Authorization': `Bearer ${token}`
       },
     }).then((response) => {
-      const href = URL.createObjectURL(response.data);
-      const link = document.createElement('a');
-      link.href = href;
-      link.setAttribute('download', `${data.file_name}_hr.png`);
-      document.body.appendChild(link);
-      link.click();
-      document.body.removeChild(link);
-      URL.revokeObjectURL(href);
+      return response;
     });
   },
   async uploadArticle(token: string, article_data: ArticleCreate) {
@@ -101,12 +136,5 @@ export const api = {
   },
   async getVideos(token: string) {
     return axios.get<Video[]>(`${apiUrl}/api/v1/videos/`, authHeaders(token));
-  },
-  async googleLogin(username: string) {
-    const params = new URLSearchParams();
-    params.append("username", username);
-    params.append("password", "google");
-
-    return axios.post(`${apiUrl}/api/v1/login/google/access-token`, params);
-  },
+  }
 };

BIN
frontend/src/assets/img/qrcode/Angela.png


BIN
frontend/src/assets/img/qrcode/aianchor3-title.png


BIN
frontend/src/assets/img/qrcode/aianchor3bg.png


BIN
frontend/src/assets/img/qrcode/angela.webp


BIN
frontend/src/assets/img/qrcode/button1.png


BIN
frontend/src/assets/img/qrcode/button2.png


BIN
frontend/src/assets/img/qrcode/button3.png


BIN
frontend/src/assets/img/qrcode/choozmologo.png


BIN
frontend/src/assets/img/qrcode/icon-19.png


BIN
frontend/src/assets/img/qrcode/icon-20.png


BIN
frontend/src/assets/img/qrcode/line1.png


BIN
frontend/src/assets/img/qrcode/line2.png


BIN
frontend/src/assets/img/qrcode/moichiu.png


BIN
frontend/src/assets/img/qrcode/moichiu.webp


BIN
frontend/src/assets/img/qrcode/startline.png


BIN
frontend/src/assets/img/qrcode/startline1.png


BIN
frontend/src/assets/img/qrcode/儲值卷-06.png


BIN
frontend/src/assets/img/qrcode/儲值卷-07.png


BIN
frontend/src/assets/img/qrcode/儲值卷-13.png


+ 14 - 4
frontend/src/components/Dialog.vue

@@ -17,6 +17,14 @@ const props = defineProps({
   state: {
     type: String,
   },
+  icon: {
+    type: String,
+    default: "info"
+  },
+  qrcode: {
+    type: Boolean,
+    default: false,
+  }
 });
 
 const emit = defineEmits(["close"]);
@@ -35,12 +43,14 @@ function close() {
     <v-card>
       <v-card-text>
         <section class="d-flex flex-column align-center">
-          <v-icon style="font-size: 70px" icon="info" :color="state" />
-          <p class="mt-3">{{ msg }}</p>
+          <v-icon style="font-size: 70px" :icon="icon" :color="state" />
+          <!-- <p class="mt-3">{{ msg }}</p> -->
+          <p  v-html="msg" class="mt-3 text-center"></p>
         </section>
       </v-card-text>
-      <v-card-actions>
-        <v-btn @click="close()" class="mx-auto"> {{ t("close") }}</v-btn>
+      <v-card-actions class="d-flex justify-space-evenly">
+        <v-btn @click="close()" v-show="!qrcode || (state === 'error' && qrcode)">{{ t("close") }}</v-btn>
+        <router-link v-show="(state === 'success' || state === 'error') && qrcode" to="/main/dashboard">返回使用者頁面</router-link>
       </v-card-actions>
     </v-card>
   </v-dialog>

+ 1 - 0
frontend/src/components/Navbar.vue

@@ -12,6 +12,7 @@ let lang = reactive([
 let menu = reactive([
   { title: "login", link: "/login" },
   { title: "register", link: "/signup" },
+  // { title: "實體卡儲值", link: "/qrcode" },
 ]);
 
 function setLang(lang: String) {

+ 1 - 1
frontend/src/env.ts

@@ -5,7 +5,7 @@ let envWsUrl = "";
 
 if (env === "production") {
   envApiUrl = `https://${import.meta.env.VITE_APP_DOMAIN_PROD}`;
-  envWsUrl = `wss://${import.meta.env.VITE_APP_DOMAIN_DEV}`;
+  envWsUrl = `wss://${import.meta.env.VITE_APP_DOMAIN_PROD}`;
 } else if (env === "staging") {
   envApiUrl = `https://${import.meta.env.VITE_APP_DOMAIN_STAG}`;
 } else {

+ 3 - 1
frontend/src/interfaces/index.ts

@@ -4,6 +4,7 @@ export interface IUserProfile {
   is_superuser: boolean;
   full_name?: string;
   id: number;
+  available_time: number; 
 }
 
 export interface IUserProfileUpdate {
@@ -43,7 +44,7 @@ export interface MainState {
 export interface Video {
   id: number;
   title: string;
-  stored_file_name: string;
+  stored_filename: string;
   progress_state: string;
 }
 
@@ -64,6 +65,7 @@ export interface Image {
   stored_file_name: string;
   content: string;
   state: string;
+  link: any;
 }
 
 export interface ImageDownload {

+ 2 - 0
frontend/src/language/en.json

@@ -1,6 +1,7 @@
 {
     "login" : "Login",
     "loggedIn" : "Logged in",
+    "loggedError" : "Incorrect email or password",
     "loginLink" : "Login",
     "logout": "Logout",
     "submit": "Submit",
@@ -18,6 +19,7 @@
     "signingUp": "signing up...",
     "registerLink": "Register",
     "registerSuccess": "Successfully registered",
+    "registerError": "The user with this email already exists in the system",
     "privacy_term_1": "Registeration implies acception of \n \n \n",
     "privacy_term_2": "terms of service and privacy policy",
     "forgotPsd": "Forgot Password",

+ 2 - 0
frontend/src/language/zh.json

@@ -1,6 +1,7 @@
 {
     "login" : "登入",
     "loggedIn" : "登入成功",
+    "loggedError" : "帳號或密碼不正確",
     "loginLink" : "立即登入",
     "logout": "登出",
     "submit": "送出",
@@ -18,6 +19,7 @@
     "signingUp": "註冊中...",
     "registerLink": "立即註冊",
     "registerSuccess": "註冊成功",
+    "registerError": "此電子郵件地址已被使用",
     "privacy_term_1": "註冊即表示您已閱讀並同意",
     "privacy_term_2": "服務條款及隱私權政策",
     "forgotPsd": "忘記密碼",

+ 12 - 7
frontend/src/router/index.ts

@@ -6,10 +6,10 @@ const router = createRouter({
   history: createWebHistory(import.meta.env.BASE_URL),
   routes: [
     {
-      path: "/",
-      name:'/',
+      path: '/',
+      name: '/',
       component: () => import(/* webpackChunkName: "start" */ '@/views/main/Start.vue'),
-      children:[
+      children: [
         {
           path: 'login',
           name: 'login',
@@ -33,6 +33,11 @@ const router = createRouter({
           name: 'reset-password',
           component: () => import(/* webpackChunkName: "reset-password" */ '@/views/ResetPassword.vue'),
         },
+        {
+          path: 'qrcode',
+          name: 'qrcode',
+          component: () => import(/* webpackChunkName: "reset-password" */ '@/views/Qrcode.vue'),
+        },
         {
           path: 'main',
           name: 'main',
@@ -46,22 +51,22 @@ const router = createRouter({
             {
               path: 'make-video',
               name: 'make-video',
-              component: () => import ('@/views/main/Upload.vue'),
+              component: () => import('@/views/main/Upload.vue'),
             },
             {
               path: 'make-article',
               name: 'make-article',
-              component: () => import ('@/views/main/Article.vue'),
+              component: () => import('@/views/main/Article.vue'),
             },
             {
               path: 'make-image',
               name: 'make-image',
-              component: () => import ('@/views/main/Image.vue'),
+              component: () => import('@/views/main/Image.vue'),
             },
             {
               path: 'progress',
               name: 'progress',
-              component: () => import ('@/views/main/Progress.vue'),
+              component: () => import('@/views/main/Progress.vue'),
             },
             {
               path: 'profile',

+ 99 - 43
frontend/src/stores/main.ts

@@ -70,10 +70,56 @@ export const useMainStore = defineStore("MainStoreId", {
           await this.logOut();
         }
       } catch (err) {
+        this.addNotification({ content: i18n.global.t("loggedError"), color: "error" });
         this.setLogInError(true);
         await this.logOut();
       }
     },
+    async qrLogIn(username: string, password: string, ser_no: string) {
+      try {
+        const response = await api.qrLogInGetToken(username, password, ser_no);
+        return response;
+      } catch (error) {
+        console.log('error', error);
+      }
+    },
+    async googleLogin(username: string) {
+      try {
+        const response = await api.googleLogin(username);
+        const token: string = response.data.access_token;
+        if (token) {
+          saveLocalToken(token);
+          this.setToken(token);
+          this.setLoggedIn(true);
+          this.setLogInError(false);
+          await this.getUserProfile();
+          await this.routeLoggedIn();
+          this.addNotification({ content: i18n.global.t("loggedIn"), color: "success" });
+        } else {
+          await this.logOut();
+        }
+      } catch (err) {
+        this.setLogInError(true);
+        await this.logOut();
+      }
+    },
+    async qrGoogleLogin(username: string, ser_no: string) {
+      try {
+        const response = await api.qrGoogleLogin(username, ser_no);
+        return response;
+      } catch (error) {
+        console.log('error', error);
+      }
+    },
+    async qrAddTime(code: string) {
+      try {
+        const response = await api.qrAddTime(this.token, code);
+        return response;
+      } catch (error) {
+        console.log('error',error);
+        return error;
+      }
+    },
     async getUserProfile() {
       try {
         const response = await api.getMe(this.token);
@@ -128,6 +174,30 @@ export const useMainStore = defineStore("MainStoreId", {
         }
       }
     },
+    async qrCheckLoggedIn() {
+      if (!this.isLoggedIn) {
+        let token = this.token;
+        if (!token) {
+          const localToken = getLocalToken();
+          if (localToken) {
+            this.setToken(localToken);
+            token = localToken;
+          }
+        }
+        if (token) {
+          try {
+            const response = await api.getMe(token);
+            this.setLoggedIn(true);
+            this.setUserProfile(response.data);
+            // router.push("/main/dashboard");
+          } catch (error) {
+            await this.removeLogIn();
+          }
+        } else {
+          await this.removeLogIn();
+        }
+      }
+    },
     async removeLogIn() {
       removeLocalToken();
       this.setToken("");
@@ -188,6 +258,7 @@ export const useMainStore = defineStore("MainStoreId", {
           router.push("/login")
         }, 2000)
       } catch (error) {
+        this.addNotification({ content: i18n.global.t("registerError"), color: "error" });
         await this.checkApiError(error);
       }
     },
@@ -272,7 +343,8 @@ export const useMainStore = defineStore("MainStoreId", {
                 file_name: file[i].name,
                 stored_file_name: element,
                 content: "sr",
-                state: "subscribe"
+                state: "subscribe",
+                link: ""
               };
               this.addImage(tmpImage);
             }
@@ -289,38 +361,42 @@ export const useMainStore = defineStore("MainStoreId", {
         await mainStore.checkApiError(error);
       }
     },
-    async getImage(data: ImageDownload) {
-      const mainStore = useMainStore();
-      try {
-        const response = (
-          await Promise.all([
-            api.getImage(mainStore.token, data),
-            await new Promise<void>((resolve, _) => setTimeout(() => resolve(), 0)),
-          ])
-        );
-      } catch (error) {
-        await mainStore.checkApiError(error);
-      }
-    },
+    // async getImage(data: ImageDownload) {
+    //   const mainStore = useMainStore();
+    //   try {
+    //     const response = (
+    //       await Promise.all([
+    //         api.getImage(mainStore.token, data),
+    //         await new Promise<void>((resolve, _) => setTimeout(() => resolve(), 0)),
+    //       ])
+    //     );
+    //   } catch (error) {
+    //     await mainStore.checkApiError(error);
+    //   }
+    // },
     addImage(payload: Image) {
       this.images.push(payload);
     },
-    finishImage(payload: string) {
+    async finishImage(payload: ImageDownload) {
       const WS = new WebSocket(`${wsUrl}/api/v1/images/sr`);
       let image = this.images.filter(e => {
-        return payload.includes(e.stored_file_name)
+        return payload.stored_file_name.includes(e.stored_file_name)
       });
-
-      if (image) {
-        image.map(e => {
-          e.state = "completed";
-        })
+      const mainStore = useMainStore();
+      try {
+        const response = await api.downloadImage(mainStore.token, payload);
+        if (image) {
+          image.map(e => {
+            e.state = "completed";
+            e.link = response.data;
+          })
+        }
+      } catch (error) {
+        await mainStore.checkApiError(error);
       }
 
       // 全部完成後回傳 WebSocket
       let processing = this.images.find(e => e.state !== "completed");
-      console.log('processing', processing);
-
       if (!processing) {
         setTimeout(() => {
           // WS.close();
@@ -361,26 +437,6 @@ export const useMainStore = defineStore("MainStoreId", {
       } catch (error) {
         await mainStore.checkApiError(error);
       }
-    },
-    async googleLogin(username: string) {
-      try {
-        const response = await api.googleLogin(username);
-        const token: string = response.data.access_token;
-        if (token) {
-          saveLocalToken(token);
-          this.setToken(token);
-          this.setLoggedIn(true);
-          this.setLogInError(false);
-          await this.getUserProfile();
-          await this.routeLoggedIn();
-          this.addNotification({ content: i18n.global.t("loggedIn"), color: "success" });
-        } else {
-          await this.logOut();
-        }
-      } catch (err) {
-        this.setLogInError(true);
-        await this.logOut();
-      }
     }
   }
 });

+ 1 - 0
frontend/src/views/Login.vue

@@ -111,6 +111,7 @@ onMounted(() => {
             :label="$t('password')"
             hint="4-12 位數密碼"
             @click:append="showPassword = !showPassword"
+            @keyup.enter="submit"
             required
           ></v-text-field>
 

+ 766 - 0
frontend/src/views/Qrcode.vue

@@ -0,0 +1,766 @@
+<script setup lang="ts">
+import { ref, reactive, computed, onMounted } from "vue";
+import { useMainStore } from "@/stores/main";
+import { useDisplay } from "vuetify";
+import { useRoute } from "vue-router";
+import { useI18n } from "vue-i18n";
+import { googleTokenLogin, decodeCredential } from "vue3-google-login";
+import type { CallbackTypes } from "vue3-google-login";
+// import Navbar from "@/components/Navbar.vue";
+import Dialog from "@/components/Dialog.vue";
+
+const mainStore = useMainStore();
+
+// variable
+const { t } = useI18n();
+const route = useRoute();
+const { name } = useDisplay();
+const email = ref("");
+const password = ref("");
+let ser_no: any = ref("");
+let showPassword = ref(false);
+let loginState = ref(false);
+let loading = ref(false);
+let dialog = reactive({
+  msg: "",
+  state: "",
+  show: false,
+  icon: "check_circle",
+});
+
+// getter
+const width = computed(() => {
+  switch (name.value) {
+    case "xs":
+      return 12;
+    case "sm":
+      return 8;
+  }
+});
+
+function setDialog(status: Boolean, msg: String = "") {
+  if (status) {
+    dialog.show = true;
+    dialog.state = "success";
+    dialog.msg =
+      "儲值成功!<br/>已獲得價值 1000 元的 120 秒影片製作時間<br/>(儲值成功後即可登入電腦版進行影片製作)";
+    dialog.icon = "check_circle";
+  } else {
+    dialog.show = true;
+    dialog.state = "error";
+    dialog.msg = `${msg}`;
+    dialog.icon = "highlight_off";
+  }
+}
+
+// action
+async function submit() {
+  loading.value = true;
+  if (email.value === "" || password.value === "") {
+    loading.value = false;
+    return;
+  }
+
+  let response = await mainStore.qrLogIn(
+    email.value,
+    password.value,
+    ser_no.value
+  );
+
+  loading.value = false;
+
+  if (response?.data.time_added === -1) {
+    setDialog(false, "此序號無效");
+  } else if (response?.status === 200) {
+    setTimeout(() => {
+      setDialog(true);
+    }, 500);
+  }
+}
+
+// lifecycle
+onMounted(() => {
+  console.log("onMounted");
+  if (route.query["add_time_code"]) {
+    ser_no.value = route.query["add_time_code"];
+  }
+  mainStore.qrCheckLoggedIn();
+  if (mainStore.token) {
+    checkCode("");
+  } else {
+    loginState.value = false;
+  }
+
+  async function checkCode(method: string = "") {
+    loading.value = true;
+    let response: any = await mainStore.qrAddTime(ser_no.value);
+    loading.value = false;
+    if (response.status === 200) {
+      loginState.value = true;
+      setDialog(true);
+    } else if (response.response.status === 400) {
+      setDialog(
+        false,
+        "此序號已被使用 <br> This serial number is already used"
+      );
+    }
+  }
+
+  // if (route.params.ser_no) {
+  //   ser_no.value = route.params.ser_no;
+  //   console.log("ser_no.value", ser_no.value);
+  // }
+});
+
+const callback: CallbackTypes.CredentialCallback = async (response: any) => {
+  loading.value = true;
+  const userData: any = decodeCredential(response.credential);
+  let res = await mainStore.qrGoogleLogin(userData.email, ser_no.value);
+  loading.value = false;
+  if (res?.data.time_added === -1) {
+    setDialog(false, "此序號無效");
+  } else if (res?.status === 200) {
+    setTimeout(() => {
+      setDialog(true);
+    }, 500);
+  }
+};
+</script>
+
+<template>
+  <!-- <Navbar /> -->
+  <v-container fluid class="pa-0 overflow-hidden">
+    <div class="ai_anchor3_content">
+      <div class="ai_anchor3_content_box">
+        <img
+          class="ai_anchor3_content_title img-fluid"
+          src="../assets/img/qrcode/aianchor3-title.png"
+          alt=""
+        />
+        <h1>集仕多股份有限公司</h1>
+        <h1>AI 主播&ensp;儲值禮物卡</h1>
+      </div>
+      <div class="ai_anchor3_content_start">
+        <!-- <h1>開放倒數</h1> -->
+        <img
+          class="img-fluid"
+          src="../assets/img/qrcode/startline.png"
+          alt=""
+        />
+      </div>
+
+      <v-row
+        align="center"
+        justify="center"
+        no-gutters
+        class="overflow-hidden mx-auto login-form"
+        v-if="!loginState"
+      >
+        <v-col cols="12" class="px-6 my-8 my-md-0">
+          <div class="form-title">
+            <h3>登入後即可獲得儲值金</h3>
+            <span></span>
+          </div>
+          <v-form ref="form" lazy-validation>
+            <v-text-field
+              v-model="email"
+              name="email"
+              prepend-icon="person"
+              :rules="[(v) => !!v || '請輸入您的帳號']"
+              :label="$t('emailAddress')"
+              required
+            >
+            </v-text-field>
+
+            <v-text-field
+              v-model="password"
+              name="password"
+              id="password"
+              prepend-icon="key"
+              :append-icon="showPassword ? 'visibility' : 'visibility_off'"
+              :rules="[(v) => !!v || '請輸入您的密碼']"
+              :type="showPassword ? 'text' : 'password'"
+              :label="$t('password')"
+              hint="4-12 位數密碼"
+              @click:append="showPassword = !showPassword"
+              @keyup.enter="submit"
+              required
+            >
+            </v-text-field>
+
+            <!-- <p class="text-center">
+              {{ t("haventAccount") }}
+              <router-link to="/signup">{{ t("register") }}</router-link> /
+              <router-link to="/recover-password">{{
+                t("forgotPsd")
+              }}</router-link>
+            </p> -->
+
+            <div class="d-flex flex-column">
+              <v-btn rounded="pill" @click.prevent="submit" class="login-btn">
+                {{ t("loginLink") }}
+              </v-btn>
+
+              <section class="line">
+                <p class="d-none d-sm-block">
+                  沒有帳號嗎?使用 Google 快速註冊
+                </p>
+                <p class="d-block d-sm-none">
+                  沒有帳號嗎? <br />
+                  使用 Google 快速註冊
+                </p>
+                <!-- <span></span> -->
+              </section>
+
+              <div class="mx-auto mt-5" style="max-width: 235px">
+                <GoogleLogin
+                  :callback="callback"
+                  prompt
+                  popup-type="TOKEN"
+                  class="w-100"
+                />
+              </div>
+            </div>
+          </v-form>
+        </v-col>
+      </v-row>
+
+      <div class="ai_anchor_moichiu left-70" style="background: #67b5b5">
+        <v-row align="center" no-gutters class="px-0 mx-0">
+          <v-col cols="12" sm="4">
+            <div class="line1">
+              <img
+                class="img-fluid anchor_moichiu"
+                src="../assets/img/qrcode/moichiu.webp"
+                alt=""
+              />
+              <img
+                class="ai_anchor_line1"
+                src="../assets/img/qrcode/line1.png"
+                alt=""
+              />
+            </div>
+            <div class="anchor_name">
+              <img
+                class="img-fluid"
+                src="../assets/img/qrcode/moichiu.png"
+                alt=""
+              />
+            </div>
+          </v-col>
+          <v-col :cols="width">
+            <div class="ai_anchor_moichiu_text">
+              <p>To. 親愛的 VIP</p>
+              <p>我知道您們已經準備好,開始使用 AI 主播的系統了</p>
+              <p>儲值金還在來的路上</p>
+              <!-- <p>在此之前,系統目前測試中</p> -->
+              <p>預計 2023/04/06 後開始正式上線!</p>
+            </div>
+          </v-col>
+        </v-row>
+      </div>
+      <!-- <div class="mt-100">
+        <div class="ai_anchor_moichiu" style="background: #ce96c1">
+          <v-row align="center" no-gutters class="px-0 mx-0">
+            <v-col :cols="width">
+              <div class="ai_anchor_moichiu_text">
+                <p>如果有任何不清楚的,歡迎與我們聯繫</p>
+                <p>AI 主播系統裡會有使用教學</p>
+                <p>現在即可開始使用</p>
+                <p>先登入系統,看看製作影片還需要哪些東西吧!</p>
+                <p>感謝您的耐心等候</p>
+              </div>
+            </v-col>
+            <v-col cols="4">
+              <div class="line2">
+                <img
+                  class="img-fluid anchor_angela"
+                  src="../assets/img/qrcode/angela.webp"
+                  alt=""
+                />
+                <img
+                  class="ai_anchor_line2"
+                  src="../assets/img/qrcode/line2.png"
+                  alt=""
+                />
+              </div>
+
+              <div class="anchor_name2">
+                <img
+                  class="img-fluid"
+                  src="../assets/img/qrcode/Angela.png"
+                  alt=""
+                />
+              </div>
+            </v-col>
+          </v-row>
+        </div>
+      </div> -->
+
+      <div class="progress-item text-center">
+        <v-progress-circular
+          indeterminate
+          color="primary"
+          :size="50"
+          v-if="loading"
+        ></v-progress-circular>
+      </div>
+
+      <div class="CTA_Button_div text-center">
+        <button type="button" class="CTA_Button">
+          <!-- 點我開始製作 AI 主播!<br />Log In -->
+          AI 三代主播系統 <br />
+          於 2023/04/06 正式上線!
+        </button>
+      </div>
+
+      <div class="CTA_box">
+        <a href="https://ai.choozmo.com/ai-presenter/info/" target="_blank">
+          <div class="cta-content">看更多<br />官網介紹</div>
+        </a>
+
+        <a href="https://ai.choozmo.com/contact/service/" target="_blank">
+          <div class="cta-content">
+            聯絡我們
+            <img class="icon20" src="../assets/img/qrcode/icon-19.png" alt="" />
+          </div>
+        </a>
+
+        <a href="https://line.me/R/ti/p/@choozmo?from=page" target="_blank">
+          <div class="cta-content">
+            關注我們
+            <img class="icon20" src="../assets/img/qrcode/icon-20.png" alt="" />
+          </div>
+        </a>
+      </div>
+
+      <div class="logo_box">
+        <img
+          class="choozmologo"
+          src="../assets/img/qrcode/choozmologo.png"
+          alt=""
+        />
+      </div>
+    </div>
+    <Dialog
+      :msg="dialog.msg"
+      :state="dialog.state"
+      :dialog="dialog.show"
+      :icon="dialog.icon"
+      :qrcode="true"
+      @close="dialog.show = false"
+    ></Dialog>
+  </v-container>
+</template>
+
+<style lang="scss" scoped>
+.ai_anchor3_content {
+  background-image: url("../assets/img/qrcode/aianchor3bg.png");
+  width: 100%;
+  background-size: cover;
+  background-repeat: no-repeat;
+}
+.login-form {
+  color: #fff;
+  margin-bottom: 100px;
+  .line {
+    margin-top: 50px;
+    display: flex;
+    justify-content: center;
+    position: relative;
+    p {
+      position: relative;
+      z-index: 1;
+      color: #fff;
+      letter-spacing: 1px;
+      font-size: 24px;
+      font-weight: bold;
+      text-align: center;
+      @media (max-width: 600px) {
+        font-size: 18px;
+      }
+      &::after,
+      &::before {
+        content: "";
+        display: block;
+        height: 1px;
+        width: 50px;
+        background: #fff;
+        position: absolute;
+        bottom: 18px;
+        @media (max-width: 600px) {
+          bottom: 20px;
+        }
+      }
+      &::after {
+        left: -55px;
+      }
+      &::before {
+        right: -55px;
+      }
+    }
+  }
+  .login-btn {
+    color: #fff;
+    background: #4f4fa0;
+  }
+
+  .form-title span {
+    background: #fff !important;
+  }
+}
+.logo_box {
+  text-align: end;
+  overflow: hidden;
+}
+.choozmologo {
+  width: 500px;
+  margin-right: -50px;
+  margin-bottom: -80px;
+  @media (max-width: 767px) {
+    width: 80vw;
+  }
+  @media (max-width: 376px) {
+    width: 85vw;
+  }
+}
+.cta-content {
+  width: 200px;
+  height: 130px;
+  margin: 0 20px;
+  display: flex;
+  flex-direction: column;
+  justify-content: center;
+  align-items: center;
+  border: 3px solid #fff;
+  padding: 30px 20px;
+  color: #fff;
+  border-radius: 10px;
+  text-align: center;
+  font-size: 24px;
+  @media (max-width: 767px) {
+    width: 100%;
+    margin: 5vh auto 0;
+  }
+  @media (max-width: 376px) {
+    width: 187px;
+  }
+}
+.icon20 {
+  width: 50px;
+}
+.CTA_box {
+  width: 50%;
+  margin: 100px auto;
+  display: flex;
+  justify-content: space-evenly;
+  @media (max-width: 1200px) {
+    width: 70%;
+  }
+  @media (max-width: 767px) {
+    flex-direction: column;
+  }
+}
+.CTA_Button_div {
+  margin-top: 100px;
+}
+.CTA_Button {
+  width: 400px;
+  height: 120px;
+  border-radius: 50px;
+  background: #4f4fa0;
+  transition: all 300ms ease-in-out;
+  box-shadow: 1px 15px #2e3287;
+  color: #fff;
+  border: none;
+  padding: 0px 30px;
+  font-size: 24px;
+  position: relative;
+  bottom: 0px;
+  left: 0px;
+  transition: all 300ms ease-in-out;
+  font-weight: 600;
+  letter-spacing: 2px;
+  &:hover {
+    bottom: -10px;
+    box-shadow: none;
+  }
+  @media (max-width: 991px) {
+    width: 80%;
+  }
+
+  @media (max-width: 767px) {
+    width: 80%;
+    margin-top: 20vh;
+  }
+  @media (max-width: 575px) {
+    margin-top: 25vh;
+    font-size: 20px;
+  }
+}
+.ai_anchor3_content_box {
+  width: 50vw;
+  margin: 0 auto;
+  padding-top: 150px;
+  text-align: center;
+  color: #fff;
+
+  h1 {
+    font-size: 2rem;
+  }
+  @media (max-width: 991px) {
+    width: 80vw;
+  }
+  @media (max-width: 767px) {
+    width: 90vw;
+  }
+}
+.ai_anchor_moichiu_text {
+  @media (max-width: 767px) {
+    height: 300px;
+    padding: 0;
+    display: flex;
+    flex-direction: column;
+    justify-content: center;
+  }
+}
+
+.ai_anchor3_content_start {
+  padding-top: 50px;
+  width: 93%;
+  margin: 0 auto;
+  text-align: center;
+  color: #fff;
+  margin-bottom: 15px;
+  h1 {
+    margin-bottom: -60px;
+  }
+}
+.left-70 {
+  left: 70px;
+  @media (max-width: 991px) {
+    left: 0px;
+  }
+}
+.mt-100 {
+  margin-top: 100px;
+
+  @media (max-width: 767px) {
+    margin-top: 25vh;
+  }
+  @media (max-width: 400px) {
+    margin-top: 35vh;
+  }
+}
+.ai_anchor_moichiu {
+  width: 75%;
+  margin: auto;
+  border-radius: 1rem;
+  padding: 50px 10px;
+  position: relative;
+  @media (max-width: 991px) {
+    width: 80%;
+    padding: 40px 10px;
+  }
+  @media (max-width: 767px) {
+    padding: 20px 10px;
+  }
+  @media (max-width: 767px) {
+    width: 90%;
+  }
+  .anchor_moichiu {
+    margin-left: -60px;
+
+    @media (max-width: 767px) {
+      margin-bottom: 0px;
+      margin-top: 0;
+      position: absolute;
+      width: 50vw;
+      left: 53px;
+      bottom: -134px;
+    }
+  }
+
+  .anchor_moichiu,
+  .anchor_angela {
+    width: 350px;
+    max-width: unset;
+    @media (max-width: 1200px) {
+      width: 300px;
+    }
+    @media (max-width: 991px) {
+      width: 275px;
+    }
+  }
+  .anchor_angela {
+    @media (max-width: 767px) {
+      margin-bottom: 0px;
+      margin-top: 0;
+      position: absolute;
+      right: -60vw;
+      bottom: -103px;
+    }
+  }
+  .anchor_name {
+    position: absolute;
+    bottom: -20px;
+    left: -8px;
+    width: 80px;
+
+    @media (max-width: 991px) {
+      width: 50px;
+      left: -8px;
+      bottom: -20px;
+    }
+    @media (max-width: 767px) {
+      left: 250px;
+      bottom: -170px;
+    }
+
+    img {
+      @media (max-width: 1200px) {
+        max-width: unset;
+        width: 70px;
+      }
+      @media (max-width: 991px) {
+        max-width: unset;
+        width: 65px;
+      }
+    }
+  }
+
+  .anchor_name2 {
+    position: absolute;
+    bottom: -5px;
+    right: 0;
+    width: 100px;
+    @media (max-width: 991px) {
+      bottom: -8px;
+      right: -3px;
+      width: 80px;
+    }
+    @media (max-width: 767px) {
+      bottom: -27vw;
+      right: 280px;
+      width: 100px;
+    }
+    @media (max-width: 575px) {
+      bottom: -32vw;
+      right: 270px;
+      width: 70px;
+    }
+    @media (max-width: 414px) {
+      bottom: -40vw;
+      right: 285px;
+    }
+    p {
+      color: #fff !important;
+      font-size: 14px;
+      margin-bottom: 0rem;
+    }
+    img {
+      width: 100px;
+      @media (max-width: 991px) {
+        width: 80px;
+      }
+      @media (max-width: 767px) {
+        width: 100px;
+      }
+    }
+  }
+  .line1,
+  .line2 {
+    position: relative;
+  }
+
+  .line1 {
+    @media (max-width: 767px) {
+      bottom: -360px;
+    }
+  }
+
+  .line2 {
+    @media (max-width: 991px) {
+      margin-left: 30px;
+    }
+    @media (max-width: 767px) {
+      bottom: -120px;
+    }
+  }
+  .ai_anchor_line1 {
+    max-width: 1500px;
+    position: absolute;
+    bottom: 0px;
+    left: 165px;
+    object-fit: cover;
+    @media (max-width: 1200px) {
+      max-width: 1000px;
+      left: 140px;
+    }
+    @media (max-width: 991px) {
+      width: 85vw;
+      left: 123px;
+    }
+    @media (max-width: 767px) {
+      left: 175px;
+      bottom: -138px;
+    }
+    @media (max-width: 575px) {
+      left: 168px;
+    }
+  }
+  .ai_anchor_line2 {
+    width: 1300px;
+    height: 290px;
+    position: absolute;
+    bottom: 6px;
+    left: -1238px;
+
+    @media (max-width: 1200px) {
+      width: 830px;
+      height: auto;
+      bottom: -5px;
+      left: -775px;
+    }
+
+    @media (max-width: 991px) {
+      width: 700px;
+      height: auto;
+      bottom: -5px;
+      left: -665px;
+    }
+    @media (max-width: 767px) {
+      width: 100vw;
+      bottom: -15vw;
+      left: -345px;
+    }
+    @media (max-width: 575px) {
+      left: -333px;
+    }
+  }
+  p {
+    font-weight: 900;
+    letter-spacing: 3px;
+    text-align: center;
+    margin-bottom: 0.3rem;
+    font-size: 22px;
+
+    @media (max-width: 991px) {
+      font-size: 20px;
+    }
+    @media (max-width: 767px) {
+      font-size: 16px;
+    }
+  }
+}
+.img-fluid {
+  max-width: 100%;
+  height: auto;
+}
+.progress-item {
+  position: fixed;
+  top: 50%;
+  left: 50%;
+  transform: translate(-50%, -50%);
+}
+</style>

+ 9 - 3
frontend/src/views/main/Dashboard.vue

@@ -11,6 +11,8 @@ const userProfile = mainStoreRef.readUserProfile;
 
 const greetedUser = computed(() => {
   const userProfile = mainStoreRef.readUserProfile;
+  console.log("userProfile", userProfile);
+
   if (userProfile.value) {
     if (userProfile.value!.full_name) {
       return userProfile.value!.full_name;
@@ -71,7 +73,7 @@ const greetedUser = computed(() => {
             <h3>已使用秒數</h3>
           </v-card-title>
           <v-card-text class="mt-3">
-            <strong>50</strong><small>秒</small>
+            <strong>0</strong><small>秒</small>
           </v-card-text>
           <v-divider></v-divider>
           <v-card-actions>
@@ -99,8 +101,12 @@ const greetedUser = computed(() => {
           <v-card-title primary-title>
             <h3>可使用秒數</h3>
           </v-card-title>
-          <v-card-text class="mt-3">
-            <strong>300</strong><small>秒</small>
+          <v-card-text
+            v-if="userProfile"
+            class="mt-3"
+          >
+            <strong>{{ userProfile.available_time }}</strong
+            ><small>秒</small>
           </v-card-text>
           <v-divider></v-divider>
           <v-card-actions>

+ 32 - 7
frontend/src/views/main/Image.vue

@@ -68,18 +68,43 @@ onMounted(() => {
   // webSocket
   WS.onmessage = function (e) {
     setTimeout(() => {
-      mainStore.finishImage(e.data);
+      let image: ImageDownload = {
+        file_name: "",
+        stored_file_name: "",
+      };
+
+      mainStore.images.map((item) => {
+        if (item.stored_file_name === e.data) {
+          image.file_name = item.file_name;
+          image.stored_file_name = item.stored_file_name;
+          mainStore.finishImage(image);
+        }
+      });
+      
     }, 1000);
   };
 });
 
-async function downloadImg(name: string, id: string) {
-  const data: ImageDownload = {
-    file_name: name.split(".")[0],
-    stored_file_name: id,
-  };
+async function downloadImg(file_name: string, stored_file_name: string) {
+  mainStore.images.map((item) => {
+    if (item.stored_file_name === stored_file_name) {
+      // 生成下載連結
+      const href = URL.createObjectURL(item.link);
+      const link = document.createElement("a");
+      link.href = href;
+      link.setAttribute("download", `${file_name}_hr.png`);
+      document.body.appendChild(link);
+      link.click();
+      document.body.removeChild(link);
+      URL.revokeObjectURL(href);
+    }
+  });
+  // const data: ImageDownload = {
+  //   file_name: file_name.split(".")[0],
+  //   stored_file_name: stored_file_name,
+  // };
 
-  await mainStore.getImage(data);
+  // await mainStore.getImage(data);
 }
 
 const headers = [

+ 41 - 37
frontend/src/views/main/Start.vue

@@ -1,47 +1,51 @@
-
 <template>
   <router-view></router-view>
 </template>
-  
+
 <script lang="ts">
-  import { onBeforeRouteUpdate } from 'vue-router'
-  import type { RouteLocationNormalized , NavigationGuardNext} from 'vue-router';
-  import { useMainStore } from '@/stores/main';
-  import { storeToRefs } from "pinia";
-  export default {
-    setup(){
-      onBeforeRouteUpdate((to, from, next) =>  {
-        startRouteGuard(to, from, next);
-      });
-    },
-    beforeRouteEnter(to, from, next){
+import { onBeforeRouteUpdate } from "vue-router";
+import type { RouteLocationNormalized, NavigationGuardNext } from "vue-router";
+import { useMainStore } from "@/stores/main";
+import { storeToRefs } from "pinia";
+export default {
+  setup() {
+    onBeforeRouteUpdate((to, from, next) => {
       startRouteGuard(to, from, next);
-    }
+    });
+  },
+  beforeRouteEnter(to, from, next) {
+    startRouteGuard(to, from, next);
+  },
+};
 
-  } 
-  // variable
+//function
+const startRouteGuard = async (
+  to: RouteLocationNormalized,
+  from: RouteLocationNormalized,
+  next: NavigationGuardNext
+) => {
+  const mainStore = useMainStore();
+  const mainStoreRef = storeToRefs(mainStore);
 
-  // excute in setup
-  
-  //lifecycle
-  
-  //function
-  const startRouteGuard = async (to:RouteLocationNormalized, from: RouteLocationNormalized, next: NavigationGuardNext) => {
-    const mainStore = useMainStore();
-    const mainStoreRef = storeToRefs(mainStore);
+  if (to.path === "/qrcode") {
+    next();
+    mainStore.qrCheckLoggedIn();
+  } else {
     mainStore.checkLoggedIn();
-    if (mainStoreRef.readIsLoggedIn.value) {
-      if (to.path === '/login' || to.path === '/') {
-        next('/main/dashboard');
-      } else {
-        next();
-      }
-    } else if (mainStoreRef.readIsLoggedIn.value === false) {
-      if (to.path === '/' || (to.path as string).startsWith('/main')) {
-        next('/login');
-      } else {
-        next();
-      }
+  }
+
+  if (mainStoreRef.readIsLoggedIn.value) {
+    if (to.path === "/login" || to.path === "/") {
+      next("/main/dashboard");
+    } else {
+      next();
+    }
+  } else if (mainStoreRef.readIsLoggedIn.value === false) {
+    if (to.path === "/" || (to.path as string).startsWith("/main")) {
+      next("/login");
+    } else {
+      next();
     }
-  };
+  }
+};
 </script>