feat(cafe24): 상품관리를 좌우 2분할로 — 목록(좁게) | 상세페이지 편집(넓게)
왼쪽에서 상품을 클릭하면 오른쪽에 편집기가 바로 열린다. 목록을 다시 받지 않고
오른쪽 조각만 교체한다(GET /products/{no}/pane → JS 삽입). 목록까지 다시 그리면
클릭마다 카페24 호출이 2회 더 늘어나기 때문이다. JS 가 실패하거나 없으면 각 행의
링크(/cafe24/?selected=)로 그대로 동작한다.
목록은 페이지를 없애고 전체를 한 번에 받는다(list_all_products, 1회 100개·상한
1000개). 필터·정렬을 한 페이지에만 적용하면 다음 페이지에 있는 상품이 빠져
"진열중만 보기" 가 거짓이 된다. 현재 87개라 1회 호출로 끝난다.
컬럼은 요청대로 번호·상품명·진열·판매·수정 5개다. 좁은 칸에 맞춰 진열/판매는
배지 대신 점, 수정일은 월-일만 표시하고 전체 값은 title 로 둔다. 긴 상품명은
2줄로 제한해 행 높이를 고르게 유지한다(전체 이름은 title·편집기 제목에서 확인).
진열중/판매중 체크박스는 중복 선택이 되며 둘 다 켜면 AND 다. 문서에 없는 API
필터 파라미터에 기대지 않고 받아온 뒤 파이썬에서 걸러낸다. 제목행 클릭은
오름↔내림 토글이며 한글 정렬은 localeCompare(ko) 를 쓴다.
편집 영역을 넓게 쓰려고 이 화면에서만 .erp-page 의 max-width 를 풀었다. 이때
box-sizing:border-box 를 함께 줘야 한다 — width:100% + padding:24px 이라
max-width 만 풀면 문서 전체에 가로 스크롤이 생긴다(측정으로 확인 후 수정).
편집 중 다른 상품을 클릭하거나 페이지를 벗어나면 저장 안 됨 경고를 띄운다.
옛 단독 화면(product.html)은 제거하고 /products/{no} 는 2분할 화면으로
리다이렉트한다. 편집기 조각을 두 곳에서 함께 쓰도록 _editor.html 로 분리했다.
검증: 유닛테스트 33개 통과(신규 3개 — 전체 조회의 페이지 순회·상한 처리·1회
종료). 상한 처리는 테스트가 잡아서 고쳤다(요청한 만큼 받았는지로 판정). 가짜
데이터로 렌더해 브라우저에서 실측: 왼쪽 360px·오른쪽 940px, 각 칸 독립 스크롤,
분할 영역이 화면 높이에 맞고, 가로 스크롤 없음, 정렬 오름/내림 동작 확인.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -71,6 +71,39 @@ def list_products(
|
|||||||
return products if isinstance(products, list) else []
|
return products if isinstance(products, list) else []
|
||||||
|
|
||||||
|
|
||||||
|
def list_all_products(
|
||||||
|
client: Cafe24Client,
|
||||||
|
*,
|
||||||
|
product_name: str = "",
|
||||||
|
max_items: int = 1000,
|
||||||
|
) -> tuple[list[dict[str, Any]], bool]:
|
||||||
|
"""전체 상품을 페이지를 넘겨가며 모두 가져온다.
|
||||||
|
|
||||||
|
2분할 화면의 왼쪽 목록은 페이지 없이 한 번에 보여주고 필터·정렬을 브라우저에서
|
||||||
|
처리한다. 그래야 "진열중만" 같은 필터가 전체 기준으로 정확해진다
|
||||||
|
(한 페이지만 받아 걸러내면 다음 페이지의 해당 상품이 빠진다).
|
||||||
|
|
||||||
|
반환: (상품 목록, 상한에 걸려 잘렸는지)
|
||||||
|
상품이 max_items 를 넘으면 거기서 멈춘다 — 무한 호출로 API 제한에 걸리는
|
||||||
|
것을 막기 위한 안전장치다(현재 쇼핑몰 87개, 1회 100개 조회).
|
||||||
|
"""
|
||||||
|
collected: list[dict[str, Any]] = []
|
||||||
|
while len(collected) < max_items:
|
||||||
|
want = min(PAGE_LIMIT, max_items - len(collected))
|
||||||
|
batch = list_products(
|
||||||
|
client,
|
||||||
|
limit=want,
|
||||||
|
offset=len(collected),
|
||||||
|
product_name=product_name,
|
||||||
|
)
|
||||||
|
collected.extend(batch)
|
||||||
|
if len(batch) < want:
|
||||||
|
return collected, False # 요청한 만큼 못 받았다 = 마지막 페이지
|
||||||
|
if len(collected) >= max_items:
|
||||||
|
return collected, True # 상한에서 멈췄다 — 뒤에 더 있을 수 있다
|
||||||
|
return collected, False
|
||||||
|
|
||||||
|
|
||||||
def get_product(client: Cafe24Client, product_no: int) -> dict[str, Any]:
|
def get_product(client: Cafe24Client, product_no: int) -> dict[str, Any]:
|
||||||
"""상품 1건 상세. 이 응답에 상세설명 필드까지 들어 있다."""
|
"""상품 1건 상세. 이 응답에 상세설명 필드까지 들어 있다."""
|
||||||
no = int(product_no)
|
no = int(product_no)
|
||||||
|
|||||||
@@ -1,12 +1,16 @@
|
|||||||
"""카페24 상품 화면 — 목록/검색 · 상세설명(HTML) 조회 · 편집 후 즉시 적용.
|
"""카페24 상품 화면 — 좌우 2분할(목록 | 상세페이지 편집).
|
||||||
|
|
||||||
카페24를 언제나 source of truth 로 본다. 목록도 상세설명도 화면을 열 때마다
|
화면 구성
|
||||||
API 로 현재값을 읽고, 목록 결과는 `cafe24_products` 캐시에 UPSERT 한다
|
왼쪽 전체 상품 목록. 좁게. 진열/판매 필터(중복 선택) + 제목행 클릭 정렬.
|
||||||
(예약·로그 화면에서 API 없이 상품명을 보여주기 위한 용도).
|
오른쪽 선택한 상품의 상세설명 HTML 편집기 + 버전 이력. 넓게.
|
||||||
|
|
||||||
상세설명은 상품 리소스의 필드다(`/description` 서브리소스는 존재하지 않는다 —
|
목록은 페이지를 넘겨가며 **전체**를 한 번에 받는다(`list_all_products`). 필터·정렬을
|
||||||
app/integrations/cafe24/products.py 주석 참고). 목록 응답에는 상세설명이 없어
|
브라우저에서 처리하려면 전체가 있어야 정확하다 — 한 페이지만 받아 걸러내면 다음
|
||||||
상품 1건씩 조회해야 하므로, 목록 화면에서는 미리보기를 뿌리지 않는다.
|
페이지에 있는 해당 상품이 빠진다.
|
||||||
|
|
||||||
|
상품을 클릭하면 오른쪽만 교체한다(`GET /products/{no}/pane` 이 편집기 조각을
|
||||||
|
돌려주고 JS 가 끼워 넣는다). 목록을 다시 불러오지 않으므로 카페24 호출이 1회로
|
||||||
|
끝난다. JS 가 없거나 실패하면 각 행은 그냥 링크(`/cafe24/?selected=`)로 동작한다.
|
||||||
|
|
||||||
쓰기(`POST /products/{no}/apply`)는 반드시 이 순서를 지킨다.
|
쓰기(`POST /products/{no}/apply`)는 반드시 이 순서를 지킨다.
|
||||||
카페24 현재값 재조회 → BACKUP 버전 저장 → 지문 대조(충돌 거부) → PUT →
|
카페24 현재값 재조회 → BACKUP 버전 저장 → 지문 대조(충돌 거부) → PUT →
|
||||||
@@ -25,6 +29,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
from urllib.parse import urlencode
|
||||||
|
|
||||||
from fastapi import APIRouter, Form, Request
|
from fastapi import APIRouter, Form, Request
|
||||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||||
@@ -38,15 +43,10 @@ logger = logging.getLogger("cafe24.products")
|
|||||||
|
|
||||||
products_router = APIRouter()
|
products_router = APIRouter()
|
||||||
|
|
||||||
# 한 화면에 보여줄 상품 수. 카페24 1회 조회 한도(100)를 넘지 않는다.
|
|
||||||
PAGE_SIZE = 50
|
|
||||||
|
|
||||||
|
def _checked(request: Request, name: str) -> bool:
|
||||||
def _page_param(raw: str | None) -> int:
|
"""체크박스 → bool. 값이 무엇이든 파라미터가 있으면 체크된 것으로 본다."""
|
||||||
try:
|
return request.query_params.get(name) is not None
|
||||||
return max(1, int(raw or 1))
|
|
||||||
except ValueError:
|
|
||||||
return 1
|
|
||||||
|
|
||||||
|
|
||||||
def _short_dt(value: Any) -> str:
|
def _short_dt(value: Any) -> str:
|
||||||
@@ -58,75 +58,33 @@ def _short_dt(value: Any) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def _row_for_list(raw: dict[str, Any]) -> dict[str, Any]:
|
def _row_for_list(raw: dict[str, Any]) -> dict[str, Any]:
|
||||||
"""목록 표에 쓸 필드만 골라낸다(응답 필드가 90개라 그대로 넘기지 않는다)."""
|
"""왼쪽 목록에 쓸 필드만 — 상품번호·상품명·진열·판매·최근수정."""
|
||||||
normalized = products.normalize_product(raw)
|
normalized = products.normalize_product(raw)
|
||||||
return {
|
return {
|
||||||
**normalized,
|
"product_no": normalized["product_no"],
|
||||||
|
"product_name": normalized["product_name"],
|
||||||
|
"display": normalized["display"],
|
||||||
|
"selling": normalized["selling"],
|
||||||
"updated_date": _short_dt(raw.get("updated_date")),
|
"updated_date": _short_dt(raw.get("updated_date")),
|
||||||
"price": str(raw.get("price") or ""),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@products_router.get("/", response_class=HTMLResponse)
|
def _list_query(request: Request, *, selected: int | None = None) -> str:
|
||||||
def product_list(request: Request) -> HTMLResponse:
|
"""현재 검색·필터를 유지한 목록 URL 쿼리스트링."""
|
||||||
"""상품 목록/검색. 검색어는 상품명 부분일치(카페24 API 가 처리)."""
|
params: list[tuple[str, str]] = []
|
||||||
from app.main import render_template # noqa: WPS433
|
|
||||||
|
|
||||||
checked = guard(request)
|
|
||||||
if not isinstance(checked, tuple):
|
|
||||||
return checked
|
|
||||||
st, user = checked
|
|
||||||
|
|
||||||
keyword = (request.query_params.get("q") or "").strip()
|
keyword = (request.query_params.get("q") or "").strip()
|
||||||
page = _page_param(request.query_params.get("page"))
|
if keyword:
|
||||||
|
params.append(("q", keyword))
|
||||||
api = build_cafe24_api(st)
|
for flag in ("display", "selling"):
|
||||||
rows: list[dict[str, Any]] = []
|
if _checked(request, flag):
|
||||||
total = 0
|
params.append((flag, "1"))
|
||||||
error = ""
|
if selected:
|
||||||
try:
|
params.append(("selected", str(selected)))
|
||||||
total = products.count_products(api.client, product_name=keyword)
|
return urlencode(params)
|
||||||
raw_rows = products.list_products(
|
|
||||||
api.client,
|
|
||||||
limit=PAGE_SIZE,
|
|
||||||
offset=(page - 1) * PAGE_SIZE,
|
|
||||||
product_name=keyword,
|
|
||||||
)
|
|
||||||
rows = [_row_for_list(r) for r in raw_rows]
|
|
||||||
st.upsert_products([products.normalize_product(r) for r in raw_rows])
|
|
||||||
except Cafe24Error as exc:
|
|
||||||
# 미연결/토큰만료/호출제한 모두 여기로 온다. 화면은 살려두고 사유만 알린다.
|
|
||||||
error = str(exc)
|
|
||||||
logger.warning("카페24 상품 목록 조회 실패: %s", exc)
|
|
||||||
|
|
||||||
last_page = max(1, -(-total // PAGE_SIZE)) if total else 1
|
|
||||||
|
|
||||||
ctx = base_ctx(request, user, active_tab="products")
|
|
||||||
ctx.update(
|
|
||||||
{
|
|
||||||
"page_title": "카페24 상품관리",
|
|
||||||
"page_subtitle": "상품 상세페이지 조회·편집·예약",
|
|
||||||
"rows": rows,
|
|
||||||
"keyword": keyword,
|
|
||||||
"page": page,
|
|
||||||
"last_page": last_page,
|
|
||||||
"total": total,
|
|
||||||
"error": error,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
return render_template(request, "cafe24/products.html", ctx)
|
|
||||||
|
|
||||||
|
|
||||||
@products_router.get("/products/{product_no}", response_class=HTMLResponse)
|
def _editor_ctx(st: Any, product_no: int) -> dict[str, Any]:
|
||||||
def product_detail(request: Request, product_no: int) -> HTMLResponse:
|
"""오른쪽 편집기 조각에 필요한 컨텍스트. 전체 페이지와 조각이 함께 쓴다."""
|
||||||
"""상품 1건 — 기본정보 + 카페24에 지금 올라가 있는 상세설명 HTML."""
|
|
||||||
from app.main import render_template # noqa: WPS433
|
|
||||||
|
|
||||||
checked = guard(request)
|
|
||||||
if not isinstance(checked, tuple):
|
|
||||||
return checked
|
|
||||||
st, user = checked
|
|
||||||
|
|
||||||
api = build_cafe24_api(st)
|
api = build_cafe24_api(st)
|
||||||
product: dict[str, Any] = {}
|
product: dict[str, Any] = {}
|
||||||
desc = None
|
desc = None
|
||||||
@@ -140,11 +98,7 @@ def product_detail(request: Request, product_no: int) -> HTMLResponse:
|
|||||||
logger.warning("카페24 상품 %s 조회 실패: %s", product_no, exc)
|
logger.warning("카페24 상품 %s 조회 실패: %s", product_no, exc)
|
||||||
|
|
||||||
info = products.normalize_product(product) if product else {}
|
info = products.normalize_product(product) if product else {}
|
||||||
ctx = base_ctx(request, user, active_tab="products")
|
return {
|
||||||
ctx.update(
|
|
||||||
{
|
|
||||||
"page_title": f"카페24 상품 {product_no}",
|
|
||||||
"page_subtitle": product.get("product_name") or "상품 상세페이지",
|
|
||||||
"product_no": product_no,
|
"product_no": product_no,
|
||||||
"info": {
|
"info": {
|
||||||
**info,
|
**info,
|
||||||
@@ -160,12 +114,93 @@ def product_detail(request: Request, product_no: int) -> HTMLResponse:
|
|||||||
# 지문은 **인코딩된 원본**으로 만든다(적용 직전 카페24 값과 비교하므로).
|
# 지문은 **인코딩된 원본**으로 만든다(적용 직전 카페24 값과 비교하므로).
|
||||||
"fingerprint": store.fingerprint(desc.description) if desc else "",
|
"fingerprint": store.fingerprint(desc.description) if desc else "",
|
||||||
"revisions": st.list_revisions(product_no, limit=20),
|
"revisions": st.list_revisions(product_no, limit=20),
|
||||||
|
"editor_error": error,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@products_router.get("/", response_class=HTMLResponse)
|
||||||
|
def product_list(request: Request) -> HTMLResponse:
|
||||||
|
"""2분할 화면. `selected` 가 있으면 오른쪽 편집기까지 서버에서 그린다."""
|
||||||
|
from app.main import render_template # noqa: WPS433
|
||||||
|
|
||||||
|
checked = guard(request)
|
||||||
|
if not isinstance(checked, tuple):
|
||||||
|
return checked
|
||||||
|
st, user = checked
|
||||||
|
|
||||||
|
keyword = (request.query_params.get("q") or "").strip()
|
||||||
|
only_display = _checked(request, "display")
|
||||||
|
only_selling = _checked(request, "selling")
|
||||||
|
|
||||||
|
api = build_cafe24_api(st)
|
||||||
|
rows: list[dict[str, Any]] = []
|
||||||
|
total = 0
|
||||||
|
truncated = False
|
||||||
|
error = ""
|
||||||
|
try:
|
||||||
|
raw_rows, truncated = products.list_all_products(api.client, product_name=keyword)
|
||||||
|
st.upsert_products([products.normalize_product(r) for r in raw_rows])
|
||||||
|
total = len(raw_rows)
|
||||||
|
rows = [_row_for_list(r) for r in raw_rows]
|
||||||
|
# 필터는 전체를 받아온 뒤 적용한다(문서에 없는 API 파라미터에 기대지 않는다).
|
||||||
|
if only_display:
|
||||||
|
rows = [r for r in rows if r["display"]]
|
||||||
|
if only_selling:
|
||||||
|
rows = [r for r in rows if r["selling"]]
|
||||||
|
except Cafe24Error as exc:
|
||||||
|
# 미연결/토큰만료/호출제한 모두 여기로 온다. 화면은 살려두고 사유만 알린다.
|
||||||
|
error = str(exc)
|
||||||
|
logger.warning("카페24 상품 목록 조회 실패: %s", exc)
|
||||||
|
|
||||||
|
try:
|
||||||
|
selected = int(request.query_params.get("selected") or 0)
|
||||||
|
except ValueError:
|
||||||
|
selected = 0
|
||||||
|
|
||||||
|
ctx = base_ctx(request, user, active_tab="products")
|
||||||
|
ctx.update(
|
||||||
|
{
|
||||||
|
"page_title": "카페24 상품관리",
|
||||||
|
"page_subtitle": "상품 상세페이지 조회·편집·예약",
|
||||||
|
"rows": rows,
|
||||||
|
"total": total,
|
||||||
|
"shown": len(rows),
|
||||||
|
"truncated": truncated,
|
||||||
|
"keyword": keyword,
|
||||||
|
"only_display": only_display,
|
||||||
|
"only_selling": only_selling,
|
||||||
|
"selected": selected,
|
||||||
|
"list_query": _list_query(request),
|
||||||
"error": error,
|
"error": error,
|
||||||
"flash": request.query_params.get("msg", ""),
|
"flash": request.query_params.get("msg", ""),
|
||||||
"flash_error": request.query_params.get("err", ""),
|
"flash_error": request.query_params.get("err", ""),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
return render_template(request, "cafe24/product.html", ctx)
|
if selected:
|
||||||
|
ctx.update(_editor_ctx(st, selected))
|
||||||
|
return render_template(request, "cafe24/products.html", ctx)
|
||||||
|
|
||||||
|
|
||||||
|
@products_router.get("/products/{product_no}/pane", response_class=HTMLResponse)
|
||||||
|
def product_pane(request: Request, product_no: int) -> HTMLResponse:
|
||||||
|
"""오른쪽 편집기 조각만 — 목록을 다시 그리지 않기 위해 JS 가 가져간다."""
|
||||||
|
from app.main import render_template # noqa: WPS433
|
||||||
|
|
||||||
|
checked = guard(request)
|
||||||
|
if not isinstance(checked, tuple):
|
||||||
|
return checked
|
||||||
|
st, user = checked
|
||||||
|
|
||||||
|
ctx = base_ctx(request, user, active_tab="products")
|
||||||
|
ctx.update(_editor_ctx(st, product_no))
|
||||||
|
ctx["list_query"] = _list_query(request)
|
||||||
|
return render_template(request, "cafe24/_editor.html", ctx)
|
||||||
|
|
||||||
|
|
||||||
|
@products_router.get("/products/{product_no}")
|
||||||
|
def product_redirect(request: Request, product_no: int):
|
||||||
|
"""옛 단독 화면 주소 → 2분할 화면에서 해당 상품을 선택한 상태로 보낸다."""
|
||||||
|
return RedirectResponse(url=f"/cafe24/?selected={product_no}", status_code=303)
|
||||||
|
|
||||||
|
|
||||||
@products_router.post("/products/{product_no}/apply")
|
@products_router.post("/products/{product_no}/apply")
|
||||||
@@ -175,6 +210,7 @@ def product_apply(
|
|||||||
html: str = Form(""),
|
html: str = Form(""),
|
||||||
base_fingerprint: str = Form(""),
|
base_fingerprint: str = Form(""),
|
||||||
memo: str = Form(""),
|
memo: str = Form(""),
|
||||||
|
list_query: str = Form(""),
|
||||||
):
|
):
|
||||||
"""편집한 HTML 을 카페24에 즉시 적용한다.
|
"""편집한 HTML 을 카페24에 즉시 적용한다.
|
||||||
|
|
||||||
@@ -193,12 +229,14 @@ def product_apply(
|
|||||||
st, user = checked
|
st, user = checked
|
||||||
|
|
||||||
actor = str(user.get("email") or "")
|
actor = str(user.get("email") or "")
|
||||||
back = f"/cafe24/products/{product_no}"
|
# 적용 후에는 검색·필터를 유지한 채 같은 상품이 선택된 화면으로 돌아온다.
|
||||||
|
base = f"/cafe24/?{list_query}" if list_query else f"/cafe24/?selected={product_no}"
|
||||||
|
back = base if f"selected={product_no}" in base else f"{base}&selected={product_no}"
|
||||||
|
|
||||||
submitted = store.encode_html_urls(html or "")
|
submitted = store.encode_html_urls(html or "")
|
||||||
if not submitted.strip():
|
if not submitted.strip():
|
||||||
return RedirectResponse(
|
return RedirectResponse(
|
||||||
url=f"{back}?err=내용이 비어 있습니다. 상세페이지를 비우려면 카페24 관리자에서 하세요.",
|
url=f"{back}&err=내용이 비어 있습니다. 상세페이지를 비우려면 카페24 관리자에서 하세요.",
|
||||||
status_code=303,
|
status_code=303,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -210,7 +248,7 @@ def product_apply(
|
|||||||
actor=actor, action="apply_description", product_no=product_no,
|
actor=actor, action="apply_description", product_no=product_no,
|
||||||
result="FAIL", detail=f"현재값 조회 실패: {exc}",
|
result="FAIL", detail=f"현재값 조회 실패: {exc}",
|
||||||
)
|
)
|
||||||
return RedirectResponse(url=f"{back}?err=카페24 현재값을 읽지 못해 중단했습니다: {exc}", status_code=303)
|
return RedirectResponse(url=f"{back}&err=카페24 현재값을 읽지 못해 중단했습니다: {exc}", status_code=303)
|
||||||
|
|
||||||
backup_id = st.add_revision(
|
backup_id = st.add_revision(
|
||||||
product_no=product_no,
|
product_no=product_no,
|
||||||
@@ -226,12 +264,12 @@ def product_apply(
|
|||||||
revision_id=backup_id, result="FAIL", detail="충돌 — 편집 중 카페24 값이 변경됨",
|
revision_id=backup_id, result="FAIL", detail="충돌 — 편집 중 카페24 값이 변경됨",
|
||||||
)
|
)
|
||||||
return RedirectResponse(
|
return RedirectResponse(
|
||||||
url=f"{back}?err=편집하는 동안 카페24 값이 변경되었습니다. 새로고침해 현재 내용을 확인한 뒤 다시 적용하세요.",
|
url=f"{back}&err=편집하는 동안 카페24 값이 변경되었습니다. 새로고침해 현재 내용을 확인한 뒤 다시 적용하세요.",
|
||||||
status_code=303,
|
status_code=303,
|
||||||
)
|
)
|
||||||
|
|
||||||
if submitted == current.description:
|
if submitted == current.description:
|
||||||
return RedirectResponse(url=f"{back}?msg=변경된 내용이 없어 적용하지 않았습니다.", status_code=303)
|
return RedirectResponse(url=f"{back}&msg=변경된 내용이 없어 적용하지 않았습니다.", status_code=303)
|
||||||
|
|
||||||
# 미분리 상품은 모바일도 함께 맞춘다(PC 만 바꾸면 모바일이 어긋난다).
|
# 미분리 상품은 모바일도 함께 맞춘다(PC 만 바꾸면 모바일이 어긋난다).
|
||||||
mobile_html = None if current.separated_mobile else submitted
|
mobile_html = None if current.separated_mobile else submitted
|
||||||
@@ -246,7 +284,7 @@ def product_apply(
|
|||||||
)
|
)
|
||||||
logger.warning("카페24 상품 %s 적용 실패: %s", product_no, exc)
|
logger.warning("카페24 상품 %s 적용 실패: %s", product_no, exc)
|
||||||
return RedirectResponse(
|
return RedirectResponse(
|
||||||
url=f"{back}?err=적용에 실패했습니다: {exc} (직전 내용은 버전 {backup_id} 로 보관됨)",
|
url=f"{back}&err=적용에 실패했습니다: {exc} (직전 내용은 버전 {backup_id} 로 보관됨)",
|
||||||
status_code=303,
|
status_code=303,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -265,6 +303,6 @@ def product_apply(
|
|||||||
)
|
)
|
||||||
logger.info("카페24 상품 %s 상세설명 적용 (%s)", product_no, actor)
|
logger.info("카페24 상품 %s 상세설명 적용 (%s)", product_no, actor)
|
||||||
return RedirectResponse(
|
return RedirectResponse(
|
||||||
url=f"{back}?msg=카페24에 적용했습니다. 직전 내용은 버전 {backup_id} 로 보관됩니다.",
|
url=f"{back}&msg=카페24에 적용했습니다. 직전 내용은 버전 {backup_id} 로 보관됩니다.",
|
||||||
status_code=303,
|
status_code=303,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,102 @@
|
|||||||
|
{# 오른쪽 편집기 조각.
|
||||||
|
전체 페이지(products.html)가 include 하고, JS 가 /products/{no}/pane 으로
|
||||||
|
같은 조각만 다시 받아 끼워 넣는다. 그래서 여기에는 <script> 를 두지 않는다
|
||||||
|
(innerHTML 로 삽입된 script 는 실행되지 않는다 — JS 는 products.html 에 있고
|
||||||
|
삽입 후 cf24BindEditor() 로 다시 연결한다). #}
|
||||||
|
|
||||||
|
{% if editor_error %}
|
||||||
|
<div class="cf24-flash cf24-flash-err">
|
||||||
|
카페24 조회에 실패했습니다: {{ editor_error }}<br />
|
||||||
|
<a href="/cafe24/system">시스템 화면에서 연결 상태를 확인하세요.</a>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="cf24-editor-head">
|
||||||
|
<div>
|
||||||
|
<h3 class="cf24-editor-title">{{ info.product_name or '상품' }}</h3>
|
||||||
|
<p class="cf24-editor-sub">
|
||||||
|
상품번호 {{ product_no }}
|
||||||
|
{% if info.product_code %}· <code>{{ info.product_code }}</code>{% endif %}
|
||||||
|
{% if info.price %}· {{ info.price }}{% endif %}
|
||||||
|
{% if info.updated_date %}· 최근 수정 {{ info.updated_date }}{% endif %}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="cf24-editor-badges">
|
||||||
|
{% if info.display %}<span class="erp-badge cf24-badge-ok">진열</span>
|
||||||
|
{% else %}<span class="erp-badge cf24-badge-off">미진열</span>{% endif %}
|
||||||
|
{% if info.selling %}<span class="erp-badge cf24-badge-ok">판매</span>
|
||||||
|
{% else %}<span class="erp-badge cf24-badge-off">중지</span>{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if desc %}
|
||||||
|
<p class="cf24-note">
|
||||||
|
{% if desc.separated_mobile %}
|
||||||
|
<strong>PC/모바일 분리 사용 상품입니다.</strong> 아래 적용은 PC 만 바꿉니다 —
|
||||||
|
모바일({{ desc.mobile_description | length }}자)은 카페24 관리자에서 따로 반영해야 합니다.
|
||||||
|
{% else %}
|
||||||
|
모바일은 PC와 동일 설정이라 적용 시 <strong>함께 반영</strong>됩니다.
|
||||||
|
{% endif %}
|
||||||
|
{% if desc.mobile_differs %}<span class="cf24-warn">현재 PC/모바일 내용이 다릅니다.</span>{% endif %}
|
||||||
|
이미지 경로의 한글 파일명은 카페24에 <code>%EC%9A%A9…</code> 로 저장되어 있습니다.
|
||||||
|
여기서는 읽기 쉽게 한글로 보여주고, 적용할 때 원래 형식으로 되돌립니다.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<form class="cf24-editor-form" method="post"
|
||||||
|
action="/cafe24/products/{{ product_no }}/apply"
|
||||||
|
data-confirm="카페24 쇼핑몰에 바로 반영됩니다. 적용할까요? 직전 내용은 자동으로 백업되어 되돌릴 수 있습니다.">
|
||||||
|
<input type="hidden" name="base_fingerprint" value="{{ fingerprint }}" />
|
||||||
|
<input type="hidden" name="list_query" value="{{ list_query }}" />
|
||||||
|
|
||||||
|
<div class="cf24-editor-bar">
|
||||||
|
<span class="cf24-muted">PC 상세설명 HTML · {{ desc.description | length }}자</span>
|
||||||
|
<span class="cf24-editor-bar-right">
|
||||||
|
<input class="cf24-memo" type="text" name="memo" maxlength="200"
|
||||||
|
placeholder="변경 메모 (버전 이력에 남습니다)" />
|
||||||
|
<button class="erp-btn erp-btn-outline" type="button" data-copy="cf24-html-pc">복사</button>
|
||||||
|
<button class="erp-btn erp-btn-primary" type="submit">카페24에 적용</button>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<textarea id="cf24-html-pc" class="cf24-html cf24-html-main" name="html"
|
||||||
|
spellcheck="false">{{ html_pc }}</textarea>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{% if desc.separated_mobile or desc.mobile_differs %}
|
||||||
|
<details class="cf24-details">
|
||||||
|
<summary>모바일 상세설명 HTML 보기 (읽기 전용 · {{ desc.mobile_description | length }}자)</summary>
|
||||||
|
<textarea id="cf24-html-mo" class="cf24-html" rows="12" readonly
|
||||||
|
spellcheck="false">{{ html_mobile }}</textarea>
|
||||||
|
<div class="cf24-actions">
|
||||||
|
<button type="button" class="erp-btn erp-btn-outline" data-copy="cf24-html-mo">복사</button>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<details class="cf24-details">
|
||||||
|
<summary>버전 이력 {% if revisions %}({{ revisions | length }}건){% endif %}</summary>
|
||||||
|
{% if revisions %}
|
||||||
|
<div class="cf24-scroll">
|
||||||
|
<table class="erp-table cf24-compact">
|
||||||
|
<thead>
|
||||||
|
<tr><th>시각</th><th>유형</th><th>길이</th><th>작업자</th><th>메모</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for rev in revisions %}
|
||||||
|
<tr>
|
||||||
|
<td class="cf24-nowrap">{{ rev.created_at }}</td>
|
||||||
|
<td class="cf24-nowrap"><code>{{ rev.revision_type }}</code></td>
|
||||||
|
<td class="cf24-nowrap">{{ rev.html_length }}자</td>
|
||||||
|
<td class="cf24-nowrap">{{ rev.created_by or '—' }}</td>
|
||||||
|
<td>{{ rev.memo or '' }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<p class="cf24-muted">버전 선택 복원은 Phase 6 에서 붙습니다. 내용은 모두 보관됩니다.</p>
|
||||||
|
{% else %}
|
||||||
|
<p class="cf24-muted">아직 이 상품의 변경 이력이 없습니다.</p>
|
||||||
|
{% endif %}
|
||||||
|
</details>
|
||||||
|
{% endif %}
|
||||||
@@ -1,180 +0,0 @@
|
|||||||
{% extends "erp_base.html" %}
|
|
||||||
|
|
||||||
{% block head_extra %}
|
|
||||||
<link rel="stylesheet" href="/static/cafe24.css?v=20260814d" />
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% block content %}
|
|
||||||
{% include "cafe24/_nav.html" %}
|
|
||||||
|
|
||||||
{% if flash %}<div class="cf24-flash cf24-flash-ok">{{ flash }}</div>{% endif %}
|
|
||||||
{% if flash_error %}<div class="cf24-flash cf24-flash-err">{{ flash_error }}</div>{% endif %}
|
|
||||||
|
|
||||||
{% if error %}
|
|
||||||
<div class="cf24-flash cf24-flash-err">
|
|
||||||
카페24 조회에 실패했습니다: {{ error }}<br />
|
|
||||||
<a href="/cafe24/system">시스템 화면에서 연결 상태를 확인하세요.</a>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{# ── 기본 정보 ─────────────────────────────────────────────── #}
|
|
||||||
<div class="erp-card cf24-card">
|
|
||||||
<div class="cf24-card-head">
|
|
||||||
<h3>{{ info.product_name or '상품' }}</h3>
|
|
||||||
<span class="cf24-muted">상품번호 {{ product_no }}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<table class="erp-table cf24-kv">
|
|
||||||
<tbody>
|
|
||||||
<tr><th>상품코드</th><td><code>{{ info.product_code or '—' }}</code></td></tr>
|
|
||||||
<tr><th>판매가</th><td>{{ info.price or '—' }}</td></tr>
|
|
||||||
<tr>
|
|
||||||
<th>진열 / 판매</th>
|
|
||||||
<td>
|
|
||||||
{% if info.display %}<span class="erp-badge cf24-badge-ok">진열</span>
|
|
||||||
{% else %}<span class="erp-badge cf24-badge-off">미진열</span>{% endif %}
|
|
||||||
{% if info.selling %}<span class="erp-badge cf24-badge-ok">판매</span>
|
|
||||||
{% else %}<span class="erp-badge cf24-badge-off">중지</span>{% endif %}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr><th>최근 수정</th><td>{{ info.updated_date or '—' }}</td></tr>
|
|
||||||
<tr><th>요약설명</th><td>{{ info.summary_description or '—' }}</td></tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
|
|
||||||
<div class="cf24-actions">
|
|
||||||
<a class="erp-btn erp-btn-outline" href="/cafe24/">← 상품 목록</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{# ── 상세설명 편집 ─────────────────────────────────────────── #}
|
|
||||||
{% if desc %}
|
|
||||||
<div class="erp-card cf24-card">
|
|
||||||
<div class="cf24-card-head">
|
|
||||||
<h3>상세페이지 HTML 편집</h3>
|
|
||||||
<span class="cf24-muted">적용하면 쇼핑몰에 바로 반영됩니다</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<table class="erp-table cf24-kv">
|
|
||||||
<tbody>
|
|
||||||
<tr><th>PC 상세설명</th><td>{{ desc.description | length }}자</td></tr>
|
|
||||||
<tr>
|
|
||||||
<th>모바일 상세설명</th>
|
|
||||||
<td>
|
|
||||||
{{ desc.mobile_description | length }}자 ·
|
|
||||||
{% if desc.separated_mobile %}
|
|
||||||
<span class="cf24-warn">PC와 분리 사용</span> — 아래 적용은 PC 만 바꿉니다.
|
|
||||||
모바일은 카페24 관리자에서 따로 반영해야 합니다.
|
|
||||||
{% else %}
|
|
||||||
<span class="cf24-muted">PC와 동일 설정</span> — 적용 시 모바일도 같은 내용으로 함께 반영됩니다.
|
|
||||||
{% endif %}
|
|
||||||
{% if desc.mobile_differs %}<span class="cf24-warn">현재 내용 불일치</span>{% endif %}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
|
|
||||||
<p class="cf24-note">
|
|
||||||
이미지 경로의 한글 파일명은 카페24에 <code>%EC%9A%A9…</code> 형태로 저장되어 있습니다.
|
|
||||||
편집기에서는 읽기 쉽게 <strong>한글로 풀어서</strong> 보여주고, 적용할 때 원래 형식으로
|
|
||||||
되돌려 저장합니다. 경로를 직접 고칠 때도 한글로 그냥 쓰시면 됩니다.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<form method="post" action="/cafe24/products/{{ product_no }}/apply"
|
|
||||||
onsubmit="return confirm('카페24 쇼핑몰에 바로 반영됩니다. 적용할까요?\n\n직전 내용은 자동으로 백업되어 되돌릴 수 있습니다.');">
|
|
||||||
<input type="hidden" name="base_fingerprint" value="{{ fingerprint }}" />
|
|
||||||
|
|
||||||
<label class="cf24-label" for="cf24-html-pc">PC 상세설명 HTML</label>
|
|
||||||
<textarea id="cf24-html-pc" class="cf24-html" name="html" rows="22"
|
|
||||||
spellcheck="false">{{ html_pc }}</textarea>
|
|
||||||
|
|
||||||
<div class="cf24-toolbar" style="margin-top:12px;">
|
|
||||||
<input class="cf24-search" style="flex:1 1 320px;" type="text" name="memo" maxlength="200"
|
|
||||||
placeholder="변경 메모 (버전 이력에 남습니다 — 예: 8월 프로모션 배너 교체)" />
|
|
||||||
<button class="erp-btn erp-btn-primary" type="submit">카페24에 적용</button>
|
|
||||||
<button class="erp-btn erp-btn-outline" type="button" data-copy="cf24-html-pc">HTML 복사</button>
|
|
||||||
<a class="erp-btn erp-btn-outline" href="/cafe24/products/{{ product_no }}">되돌리기(새로고침)</a>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
{% if desc.separated_mobile or desc.mobile_differs %}
|
|
||||||
<label class="cf24-label" for="cf24-html-mo">모바일 상세설명 HTML (읽기 전용)</label>
|
|
||||||
<textarea id="cf24-html-mo" class="cf24-html" rows="12" readonly spellcheck="false"
|
|
||||||
>{{ html_mobile }}</textarea>
|
|
||||||
<div class="cf24-actions">
|
|
||||||
<button type="button" class="erp-btn erp-btn-outline" data-copy="cf24-html-mo">HTML 복사</button>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{# ── 버전 이력 ─────────────────────────────────────────────── #}
|
|
||||||
<div class="erp-card cf24-card">
|
|
||||||
<div class="cf24-card-head">
|
|
||||||
<h3>버전 이력</h3>
|
|
||||||
<span class="cf24-muted">최근 20건 · 적용 직전 내용은 BACKUP 으로 자동 보관</span>
|
|
||||||
</div>
|
|
||||||
{% if revisions %}
|
|
||||||
<div class="cf24-scroll">
|
|
||||||
<table class="erp-table">
|
|
||||||
<thead>
|
|
||||||
<tr><th>시각</th><th>유형</th><th>길이</th><th>작업자</th><th>메모</th></tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{% for rev in revisions %}
|
|
||||||
<tr>
|
|
||||||
<td class="cf24-nowrap">{{ rev.created_at }}</td>
|
|
||||||
<td class="cf24-nowrap"><code>{{ rev.revision_type }}</code></td>
|
|
||||||
<td class="cf24-nowrap">{{ rev.html_length }}자</td>
|
|
||||||
<td class="cf24-nowrap">{{ rev.created_by or '—' }}</td>
|
|
||||||
<td>{{ rev.memo or '' }}</td>
|
|
||||||
</tr>
|
|
||||||
{% endfor %}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
<p class="cf24-muted">되돌리기(버전 선택 복원)는 Phase 6 에서 화면에 붙습니다. 지금도 내용은 모두 보관됩니다.</p>
|
|
||||||
{% else %}
|
|
||||||
<p class="cf24-muted">아직 이 상품의 변경 이력이 없습니다.</p>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% block scripts %}
|
|
||||||
<script>
|
|
||||||
// HTML 복사 — clipboard API 가 막히면 textarea 선택으로 대체한다.
|
|
||||||
document.querySelectorAll("[data-copy]").forEach(function (btn) {
|
|
||||||
btn.addEventListener("click", function () {
|
|
||||||
var box = document.getElementById(btn.dataset.copy);
|
|
||||||
if (!box) return;
|
|
||||||
var done = function () {
|
|
||||||
var old = btn.textContent;
|
|
||||||
btn.textContent = "복사했습니다";
|
|
||||||
setTimeout(function () { btn.textContent = old; }, 1500);
|
|
||||||
};
|
|
||||||
if (navigator.clipboard && window.isSecureContext) {
|
|
||||||
navigator.clipboard.writeText(box.value).then(done, function () { box.select(); });
|
|
||||||
} else {
|
|
||||||
box.select();
|
|
||||||
try { document.execCommand("copy"); done(); } catch (e) { /* 사용자가 직접 복사 */ }
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// 편집 중 실수로 페이지를 벗어나는 것 방지.
|
|
||||||
(function () {
|
|
||||||
var box = document.getElementById("cf24-html-pc");
|
|
||||||
if (!box || box.readOnly) return;
|
|
||||||
var initial = box.value;
|
|
||||||
var form = box.form;
|
|
||||||
var submitting = false;
|
|
||||||
if (form) form.addEventListener("submit", function () { submitting = true; });
|
|
||||||
window.addEventListener("beforeunload", function (e) {
|
|
||||||
if (!submitting && box.value !== initial) {
|
|
||||||
e.preventDefault();
|
|
||||||
e.returnValue = "";
|
|
||||||
}
|
|
||||||
});
|
|
||||||
})();
|
|
||||||
</script>
|
|
||||||
{% endblock %}
|
|
||||||
@@ -1,11 +1,15 @@
|
|||||||
{% extends "erp_base.html" %}
|
{% extends "erp_base.html" %}
|
||||||
|
|
||||||
{% block head_extra %}
|
{% block head_extra %}
|
||||||
<link rel="stylesheet" href="/static/cafe24.css?v=20260814d" />
|
<link rel="stylesheet" href="/static/cafe24.css?v=20260814e" />
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
{% include "cafe24/_nav.html" %}
|
{% include "cafe24/_nav.html" %}
|
||||||
|
{% set qs = (list_query ~ '&') if list_query else '' %}
|
||||||
|
|
||||||
|
{% if flash %}<div class="cf24-flash cf24-flash-ok">{{ flash }}</div>{% endif %}
|
||||||
|
{% if flash_error %}<div class="cf24-flash cf24-flash-err">{{ flash_error }}</div>{% endif %}
|
||||||
|
|
||||||
{% if error %}
|
{% if error %}
|
||||||
<div class="cf24-flash cf24-flash-err">
|
<div class="cf24-flash cf24-flash-err">
|
||||||
@@ -14,74 +18,217 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<div class="erp-card cf24-card">
|
<div class="cf24-split">
|
||||||
<div class="cf24-card-head">
|
|
||||||
<h3>상품 목록</h3>
|
|
||||||
<span class="cf24-muted">
|
|
||||||
{% if keyword %}“{{ keyword }}” 검색 결과 {{ total }}건{% else %}전체 {{ total }}건{% endif %}
|
|
||||||
· {{ page }} / {{ last_page }} 페이지
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<form class="cf24-toolbar" method="get" action="/cafe24/">
|
{# ── 왼쪽: 상품 목록 ──────────────────────────────────────── #}
|
||||||
|
<aside class="erp-card cf24-pane cf24-pane-list">
|
||||||
|
<form class="cf24-filters" method="get" action="/cafe24/" id="cf24-filter-form">
|
||||||
|
{% if selected %}<input type="hidden" name="selected" value="{{ selected }}" />{% endif %}
|
||||||
<input class="cf24-search" type="search" name="q" value="{{ keyword }}"
|
<input class="cf24-search" type="search" name="q" value="{{ keyword }}"
|
||||||
placeholder="상품명으로 검색 (부분일치)" />
|
placeholder="상품명 검색" />
|
||||||
<button class="erp-btn erp-btn-primary" type="submit">검색</button>
|
<div class="cf24-checks">
|
||||||
{% if keyword %}<a class="erp-btn erp-btn-outline" href="/cafe24/">전체보기</a>{% endif %}
|
<label><input type="checkbox" name="display" value="1"
|
||||||
|
{% if only_display %}checked{% endif %} /> 진열중</label>
|
||||||
|
<label><input type="checkbox" name="selling" value="1"
|
||||||
|
{% if only_selling %}checked{% endif %} /> 판매중</label>
|
||||||
|
</div>
|
||||||
|
<div class="cf24-list-count">
|
||||||
|
{{ shown }}건{% if shown != total %} / 전체 {{ total }}건{% endif %}
|
||||||
|
{% if truncated %}<span class="cf24-warn">(상한 도달)</span>{% endif %}
|
||||||
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
{% if rows %}
|
<div class="cf24-list-scroll">
|
||||||
<div class="cf24-scroll">
|
<table class="erp-table cf24-list-table" id="cf24-list">
|
||||||
<table class="erp-table">
|
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>상품번호</th><th>상품코드</th><th>상품명</th>
|
<th class="cf24-col-no" data-sort-key="no" data-sort-type="num">번호</th>
|
||||||
<th>진열</th><th>판매</th><th>판매가</th><th>최근 수정</th><th></th>
|
<th class="cf24-col-name" data-sort-key="name" data-sort-type="text">상품명</th>
|
||||||
|
<th class="cf24-col-flag" data-sort-key="display" data-sort-type="num">진열</th>
|
||||||
|
<th class="cf24-col-flag" data-sort-key="selling" data-sort-type="num">판매</th>
|
||||||
|
<th class="cf24-col-date" data-sort-key="updated" data-sort-type="text">수정</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{% for r in rows %}
|
{% for r in rows %}
|
||||||
<tr>
|
<tr class="cf24-row {% if selected == r.product_no %}is-active{% endif %}"
|
||||||
<td class="cf24-nowrap">{{ r.product_no }}</td>
|
data-no="{{ r.product_no }}"
|
||||||
<td class="cf24-nowrap"><code>{{ r.product_code }}</code></td>
|
data-name="{{ r.product_name }}"
|
||||||
<td>{{ r.product_name }}</td>
|
data-display="{{ 1 if r.display else 0 }}"
|
||||||
<td class="cf24-nowrap">
|
data-selling="{{ 1 if r.selling else 0 }}"
|
||||||
{% if r.display %}<span class="erp-badge cf24-badge-ok">진열</span>
|
data-updated="{{ r.updated_date }}">
|
||||||
{% else %}<span class="erp-badge cf24-badge-off">미진열</span>{% endif %}
|
<td class="cf24-col-no">{{ r.product_no }}</td>
|
||||||
|
<td class="cf24-col-name" title="{{ r.product_name }}">
|
||||||
|
<a href="/cafe24/?{{ qs }}selected={{ r.product_no }}">{{ r.product_name }}</a>
|
||||||
</td>
|
</td>
|
||||||
<td class="cf24-nowrap">
|
<td class="cf24-col-flag">
|
||||||
{% if r.selling %}<span class="erp-badge cf24-badge-ok">판매</span>
|
{% if r.display %}<span class="cf24-dot cf24-dot-on" title="진열중"></span>
|
||||||
{% else %}<span class="erp-badge cf24-badge-off">중지</span>{% endif %}
|
{% else %}<span class="cf24-dot" title="미진열"></span>{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td class="cf24-nowrap">{{ r.price }}</td>
|
<td class="cf24-col-flag">
|
||||||
<td class="cf24-nowrap">{{ r.updated_date }}</td>
|
{% if r.selling %}<span class="cf24-dot cf24-dot-on" title="판매중"></span>
|
||||||
<td class="cf24-nowrap">
|
{% else %}<span class="cf24-dot" title="판매중지"></span>{% endif %}
|
||||||
<a class="erp-btn erp-btn-outline" href="/cafe24/products/{{ r.product_no }}">상세페이지</a>
|
|
||||||
</td>
|
</td>
|
||||||
|
{# 좁은 칸이라 월-일만. 전체 값은 title 로 확인 #}
|
||||||
|
<td class="cf24-col-date" title="{{ r.updated_date }}">{{ r.updated_date[5:10] }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
{% if not rows and not error %}
|
||||||
|
<p class="cf24-muted" style="padding:12px;">조건에 맞는 상품이 없습니다.</p>
|
||||||
{% if last_page > 1 %}
|
|
||||||
<div class="cf24-pager">
|
|
||||||
{% if page > 1 %}
|
|
||||||
<a class="erp-btn erp-btn-outline"
|
|
||||||
href="/cafe24/?q={{ keyword | urlencode }}&page={{ page - 1 }}">← 이전</a>
|
|
||||||
{% endif %}
|
|
||||||
<span class="cf24-muted">{{ page }} / {{ last_page }}</span>
|
|
||||||
{% if page < last_page %}
|
|
||||||
<a class="erp-btn erp-btn-outline"
|
|
||||||
href="/cafe24/?q={{ keyword | urlencode }}&page={{ page + 1 }}">다음 →</a>
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
</aside>
|
||||||
|
|
||||||
{% elif not error %}
|
{# ── 오른쪽: 상세페이지 편집 ──────────────────────────────── #}
|
||||||
|
<section class="erp-card cf24-pane cf24-pane-editor" id="cf24-editor-pane">
|
||||||
|
{% if selected %}
|
||||||
|
{% include "cafe24/_editor.html" %}
|
||||||
|
{% else %}
|
||||||
|
<div class="cf24-empty-pane">
|
||||||
|
<h3>왼쪽에서 상품을 선택하세요.</h3>
|
||||||
<p class="cf24-muted">
|
<p class="cf24-muted">
|
||||||
{% if keyword %}“{{ keyword }}” 로 찾은 상품이 없습니다.{% else %}표시할 상품이 없습니다.{% endif %}
|
선택한 상품의 상세페이지 HTML 을 여기서 바로 편집하고 카페24에 적용할 수 있습니다.<br />
|
||||||
|
적용 직전 내용은 자동으로 백업되어 되돌릴 수 있습니다.
|
||||||
</p>
|
</p>
|
||||||
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
</section>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block scripts %}
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
var pane = document.getElementById("cf24-editor-pane");
|
||||||
|
var listQuery = {{ list_query | tojson }};
|
||||||
|
var dirty = false;
|
||||||
|
|
||||||
|
// ── 편집기 조각을 새로 끼워 넣은 뒤 다시 연결 ──
|
||||||
|
window.cf24BindEditor = function () {
|
||||||
|
dirty = false;
|
||||||
|
|
||||||
|
pane.querySelectorAll("[data-copy]").forEach(function (btn) {
|
||||||
|
btn.addEventListener("click", function () {
|
||||||
|
var box = document.getElementById(btn.dataset.copy);
|
||||||
|
if (!box) return;
|
||||||
|
var done = function () {
|
||||||
|
var old = btn.textContent;
|
||||||
|
btn.textContent = "복사됨";
|
||||||
|
setTimeout(function () { btn.textContent = old; }, 1500);
|
||||||
|
};
|
||||||
|
if (navigator.clipboard && window.isSecureContext) {
|
||||||
|
navigator.clipboard.writeText(box.value).then(done, function () { box.select(); });
|
||||||
|
} else {
|
||||||
|
box.select();
|
||||||
|
try { document.execCommand("copy"); done(); } catch (e) { /* 직접 복사 */ }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
var form = pane.querySelector(".cf24-editor-form");
|
||||||
|
if (form) {
|
||||||
|
form.addEventListener("submit", function (e) {
|
||||||
|
if (!window.confirm(form.dataset.confirm)) { e.preventDefault(); return; }
|
||||||
|
dirty = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
var box = document.getElementById("cf24-html-pc");
|
||||||
|
if (box && !box.readOnly) {
|
||||||
|
box.addEventListener("input", function () { dirty = true; });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
function confirmLeave() {
|
||||||
|
return !dirty || window.confirm("편집한 내용이 저장되지 않았습니다. 이동할까요?");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 목록 클릭 → 오른쪽만 교체 ──
|
||||||
|
function select(no, push) {
|
||||||
|
pane.innerHTML = '<p class="cf24-muted" style="padding:16px;">불러오는 중…</p>';
|
||||||
|
var url = "/cafe24/products/" + no + "/pane" + (listQuery ? "?" + listQuery : "");
|
||||||
|
fetch(url, { credentials: "same-origin" })
|
||||||
|
.then(function (res) {
|
||||||
|
if (res.redirected) { window.location.href = res.url; return null; }
|
||||||
|
if (!res.ok) throw new Error("HTTP " + res.status);
|
||||||
|
return res.text();
|
||||||
|
})
|
||||||
|
.then(function (htmlText) {
|
||||||
|
if (htmlText === null) return;
|
||||||
|
pane.innerHTML = htmlText;
|
||||||
|
window.cf24BindEditor();
|
||||||
|
pane.scrollTop = 0;
|
||||||
|
if (push) {
|
||||||
|
var target = "/cafe24/?" + (listQuery ? listQuery + "&" : "") + "selected=" + no;
|
||||||
|
history.pushState({ no: no }, "", target);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(function () {
|
||||||
|
// 조각 로드가 실패하면 평범한 페이지 이동으로 대체한다.
|
||||||
|
window.location.href = "/cafe24/?" + (listQuery ? listQuery + "&" : "") + "selected=" + no;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
document.querySelectorAll("#cf24-list tbody tr.cf24-row").forEach(function (tr) {
|
||||||
|
tr.addEventListener("click", function (e) {
|
||||||
|
if (e.target.tagName === "A") e.preventDefault();
|
||||||
|
if (!confirmLeave()) return;
|
||||||
|
document.querySelectorAll("#cf24-list tr.is-active").forEach(function (el) {
|
||||||
|
el.classList.remove("is-active");
|
||||||
|
});
|
||||||
|
tr.classList.add("is-active");
|
||||||
|
select(tr.dataset.no, true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
window.addEventListener("popstate", function () {
|
||||||
|
window.location.reload();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── 필터 체크박스는 즉시 적용 ──
|
||||||
|
var filterForm = document.getElementById("cf24-filter-form");
|
||||||
|
filterForm.querySelectorAll('input[type="checkbox"]').forEach(function (cb) {
|
||||||
|
cb.addEventListener("change", function () {
|
||||||
|
if (confirmLeave()) filterForm.submit();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── 제목행 클릭 정렬(오름/내림 토글) ──
|
||||||
|
var table = document.getElementById("cf24-list");
|
||||||
|
var tbody = table.querySelector("tbody");
|
||||||
|
var sortKey = null, sortAsc = true;
|
||||||
|
|
||||||
|
table.querySelectorAll("th[data-sort-key]").forEach(function (th) {
|
||||||
|
th.classList.add("cf24-sortable");
|
||||||
|
th.addEventListener("click", function () {
|
||||||
|
var key = th.dataset.sortKey;
|
||||||
|
sortAsc = key === sortKey ? !sortAsc : true;
|
||||||
|
sortKey = key;
|
||||||
|
|
||||||
|
table.querySelectorAll("th[data-sort-key]").forEach(function (other) {
|
||||||
|
other.classList.remove("is-asc", "is-desc");
|
||||||
|
});
|
||||||
|
th.classList.add(sortAsc ? "is-asc" : "is-desc");
|
||||||
|
|
||||||
|
var numeric = th.dataset.sortType === "num";
|
||||||
|
var rows = Array.prototype.slice.call(tbody.querySelectorAll("tr.cf24-row"));
|
||||||
|
rows.sort(function (a, b) {
|
||||||
|
var x = a.dataset[key] || "", y = b.dataset[key] || "";
|
||||||
|
var cmp = numeric
|
||||||
|
? (parseFloat(x) || 0) - (parseFloat(y) || 0)
|
||||||
|
: x.localeCompare(y, "ko");
|
||||||
|
return sortAsc ? cmp : -cmp;
|
||||||
|
});
|
||||||
|
rows.forEach(function (tr) { tbody.appendChild(tr); });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
window.addEventListener("beforeunload", function (e) {
|
||||||
|
if (dirty) { e.preventDefault(); e.returnValue = ""; }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (pane.querySelector(".cf24-editor-form")) window.cf24BindEditor();
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
|
|||||||
@@ -333,6 +333,45 @@ def test_normalize_product_flags():
|
|||||||
assert products.normalize_product({"product_no": "9"})["display"] is True
|
assert products.normalize_product({"product_no": "9"})["display"] is True
|
||||||
|
|
||||||
|
|
||||||
|
class _PagingClient:
|
||||||
|
"""페이지를 넘겨가며 응답하는 가짜 클라이언트."""
|
||||||
|
|
||||||
|
def __init__(self, count: int):
|
||||||
|
self.count = count
|
||||||
|
self.calls: list[dict] = []
|
||||||
|
|
||||||
|
def get(self, path, *, params=None, json=None, product_no=None):
|
||||||
|
self.calls.append(dict(params or {}))
|
||||||
|
offset = int((params or {}).get("offset", 0))
|
||||||
|
limit = int((params or {}).get("limit", 100))
|
||||||
|
page = [{"product_no": n} for n in range(offset, min(offset + limit, self.count))]
|
||||||
|
return {"products": page}
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_all_products_walks_pages():
|
||||||
|
client = _PagingClient(230)
|
||||||
|
rows, truncated = products.list_all_products(client)
|
||||||
|
assert len(rows) == 230 and truncated is False
|
||||||
|
# 100 + 100 + 30 → 3회 호출로 끝나야 한다.
|
||||||
|
assert len(client.calls) == 3
|
||||||
|
assert [c["offset"] for c in client.calls] == [0, 100, 200]
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_all_products_stops_at_cap():
|
||||||
|
"""상한을 넘으면 잘렸다고 알린다 — 무한 호출로 API 제한에 걸리지 않게."""
|
||||||
|
client = _PagingClient(10_000)
|
||||||
|
rows, truncated = products.list_all_products(client, max_items=150)
|
||||||
|
assert len(rows) == 150 and truncated is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_all_products_single_page():
|
||||||
|
"""현재 쇼핑몰(87개)은 1회 호출로 끝난다."""
|
||||||
|
client = _PagingClient(87)
|
||||||
|
rows, truncated = products.list_all_products(client)
|
||||||
|
assert len(rows) == 87 and truncated is False
|
||||||
|
assert len(client.calls) == 1
|
||||||
|
|
||||||
|
|
||||||
def test_list_products_clamps_paging():
|
def test_list_products_clamps_paging():
|
||||||
client = _FakeClient({"products": []})
|
client = _FakeClient({"products": []})
|
||||||
products.list_products(client, limit=999, offset=-5, product_name="락")
|
products.list_products(client, limit=999, offset=-5, product_name="락")
|
||||||
|
|||||||
@@ -11,10 +11,279 @@
|
|||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 2분할 화면은 편집 영역을 최대한 넓게 쓴다 — 이 페이지에서만 폭 제한을 푼다.
|
||||||
|
box-sizing 을 함께 바꿔야 한다: .erp-page 는 width:100% + padding:24px 라서
|
||||||
|
max-width 를 풀면 padding 이 폭에 더해져 문서 전체에 가로 스크롤이 생긴다. */
|
||||||
|
.erp-page {
|
||||||
|
max-width: none;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
.cf24-card {
|
.cf24-card {
|
||||||
margin-bottom: var(--sp-16, 16px);
|
margin-bottom: var(--sp-16, 16px);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ════════════════════════════════════════════════════════════
|
||||||
|
좌우 2분할: 왼쪽 목록(좁게) | 오른쪽 상세페이지 편집(넓게)
|
||||||
|
각 칸이 따로 스크롤되고, 전체 높이는 화면에 맞춘다.
|
||||||
|
════════════════════════════════════════════════════════════ */
|
||||||
|
.cf24-split {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 360px minmax(0, 1fr);
|
||||||
|
gap: var(--sp-12, 12px);
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* .erp-page 는 flex column 이다. 위 flex-shrink:0 규칙의 예외로, 분할 영역만은
|
||||||
|
남은 높이를 모두 차지하고 안에서 스크롤되게 한다(휴가/쿠팡 모듈과 같은 방식). */
|
||||||
|
.erp-page > .cf24-split {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cf24-pane {
|
||||||
|
min-height: 0;
|
||||||
|
overflow: auto;
|
||||||
|
margin-bottom: 0;
|
||||||
|
padding: var(--sp-12, 12px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cf24-pane-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--sp-8, 8px);
|
||||||
|
overflow: hidden; /* 표만 스크롤 — 검색/필터는 고정 */
|
||||||
|
}
|
||||||
|
|
||||||
|
.cf24-pane-editor {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--sp-8, 8px);
|
||||||
|
padding: var(--sp-16, 16px);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1100px) {
|
||||||
|
.cf24-split {
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
.cf24-pane {
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
.cf24-pane-list {
|
||||||
|
max-height: 45vh;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── 왼쪽: 검색·필터 ── */
|
||||||
|
.cf24-filters {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--sp-8, 8px);
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cf24-checks {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--sp-12, 12px);
|
||||||
|
font-size: var(--text-caption, 12px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cf24-checks label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cf24-list-count {
|
||||||
|
font-size: var(--text-caption, 12px);
|
||||||
|
color: var(--color-midtone-gray, #737373);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── 왼쪽: 목록 표 (좁게) ── */
|
||||||
|
.cf24-list-scroll {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cf24-list-table {
|
||||||
|
font-size: 12px;
|
||||||
|
table-layout: fixed;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cf24-list-table thead th {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 1;
|
||||||
|
padding: 6px 4px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cf24-list-table tbody td {
|
||||||
|
padding: 6px 4px;
|
||||||
|
vertical-align: top;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 고정폭 합계를 최소로 잡아 상품명에 남은 폭을 준다.
|
||||||
|
진열/판매 칸은 제목(2글자) + 정렬 화살표가 들어갈 만큼만. */
|
||||||
|
.cf24-col-no { width: 36px; text-align: right; color: var(--color-midtone-gray, #737373); }
|
||||||
|
.cf24-col-flag { width: 40px; text-align: center; }
|
||||||
|
.cf24-col-date { width: 46px; white-space: nowrap; color: var(--color-midtone-gray, #737373); }
|
||||||
|
.cf24-col-name { word-break: break-word; }
|
||||||
|
|
||||||
|
/* 긴 상품명은 2줄까지만 — 행 높이를 고르게 유지해 목록을 훑기 쉽게 한다.
|
||||||
|
전체 이름은 title 툴팁과 오른쪽 편집기 제목에서 확인한다. */
|
||||||
|
.cf24-col-name a {
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: none;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cf24-row {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cf24-row:hover {
|
||||||
|
background: var(--color-ghost-gray, #f2f2f2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cf24-row.is-active {
|
||||||
|
background: var(--color-rich-black, #0a0a0a);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cf24-row.is-active td,
|
||||||
|
.cf24-row.is-active .cf24-col-name a {
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 진열/판매 표시는 좁은 칸이라 배지 대신 점으로 */
|
||||||
|
.cf24-dot {
|
||||||
|
display: inline-block;
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--color-subtle-ash, #e5e5e5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cf24-dot-on {
|
||||||
|
background: var(--color-success-green, #10c22b);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 제목행 클릭 정렬 */
|
||||||
|
.cf24-sortable {
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cf24-sortable::after {
|
||||||
|
content: "↕";
|
||||||
|
opacity: 0.35;
|
||||||
|
margin-left: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cf24-sortable.is-asc::after { content: "▲"; opacity: 1; }
|
||||||
|
.cf24-sortable.is-desc::after { content: "▼"; opacity: 1; }
|
||||||
|
|
||||||
|
/* ── 오른쪽: 편집기 ── */
|
||||||
|
.cf24-editor-head {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: var(--sp-12, 12px);
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cf24-editor-title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: var(--text-heading, 18px);
|
||||||
|
letter-spacing: -0.45px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cf24-editor-sub {
|
||||||
|
margin: 4px 0 0;
|
||||||
|
font-size: var(--text-caption, 12px);
|
||||||
|
color: var(--color-midtone-gray, #737373);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cf24-editor-badges {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cf24-editor-form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: 0;
|
||||||
|
gap: var(--sp-8, 8px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cf24-editor-bar {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--sp-8, 8px);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cf24-editor-bar-right {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--sp-8, 8px);
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cf24-memo {
|
||||||
|
width: 260px;
|
||||||
|
padding: 6px var(--sp-10, 10px);
|
||||||
|
border: 1px solid var(--color-subtle-ash, #e5e5e5);
|
||||||
|
border-radius: var(--r-lg, 10px);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cf24-html-main {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: 340px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cf24-details {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
border-top: 1px solid var(--color-subtle-ash, #e5e5e5);
|
||||||
|
padding-top: var(--sp-8, 8px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cf24-details > summary {
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: var(--text-caption, 12px);
|
||||||
|
color: var(--color-midtone-gray, #737373);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cf24-compact {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cf24-compact tbody td,
|
||||||
|
.cf24-compact thead th {
|
||||||
|
padding: 6px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cf24-empty-pane {
|
||||||
|
margin: auto;
|
||||||
|
text-align: center;
|
||||||
|
padding: var(--sp-24, 24px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cf24-empty-pane h3 {
|
||||||
|
margin: 0 0 var(--sp-8, 8px);
|
||||||
|
}
|
||||||
|
|
||||||
.cf24-card-head {
|
.cf24-card-head {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
+34
-5
@@ -25,14 +25,14 @@ app/integrations/cafe24/ ← 공통 (상품관리 + 향후 주문관리
|
|||||||
|
|
||||||
app/modules/cafe24/ ← 상품관리 모듈
|
app/modules/cafe24/ ← 상품관리 모듈
|
||||||
├─ router.py 루트 라우터(prefix=/cafe24) + 서브 라우터 결합
|
├─ router.py 루트 라우터(prefix=/cafe24) + 서브 라우터 결합
|
||||||
├─ routes_products.py 상품 목록/검색 · 상세설명 조회 (읽기 전용)
|
├─ routes_products.py 2분할 화면 · 편집기 조각 · 적용(쓰기)
|
||||||
├─ routes_system.py 연결(OAuth)·상태·API 로그·작업 로그
|
├─ routes_system.py 연결(OAuth)·상태·API 로그·작업 로그
|
||||||
├─ common.py 가드/컨텍스트 헬퍼 (순환 import 방지로 분리)
|
├─ common.py 가드/컨텍스트 헬퍼 (순환 import 방지로 분리)
|
||||||
├─ db.py Cafe24Store (cafe24_db, psycopg3 raw SQL)
|
├─ db.py Cafe24Store (cafe24_db, psycopg3 raw SQL)
|
||||||
├─ store.py 순수 로직 — 상수·상태전이·재시도 규칙·검증
|
├─ store.py 순수 로직 — 상수·상태전이·재시도 규칙·검증
|
||||||
├─ tests/ DB/네트워크 없는 유닛테스트
|
├─ tests/ DB/네트워크 없는 유닛테스트
|
||||||
└─ templates/cafe24/ _nav.html · products.html · product.html ·
|
└─ templates/cafe24/ _nav.html · products.html(2분할) ·
|
||||||
schedules.html · system.html
|
_editor.html(오른쪽 조각) · schedules.html · system.html
|
||||||
```
|
```
|
||||||
|
|
||||||
**규칙: 라우터에서 `httpx`/`requests` 를 직접 부르지 않는다.** 반드시
|
**규칙: 라우터에서 `httpx`/`requests` 를 직접 부르지 않는다.** 반드시
|
||||||
@@ -48,8 +48,9 @@ app/modules/cafe24/ ← 상품관리 모듈
|
|||||||
|
|
||||||
| 경로 | 화면 | 권한 |
|
| 경로 | 화면 | 권한 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `GET /cafe24/` | 상품 목록·검색 (`q`, `page`) | `cafe24` |
|
| `GET /cafe24/` | 2분할 화면 (`q`, `display`, `selling`, `selected`) | `cafe24` |
|
||||||
| `GET /cafe24/products/{product_no}` | 상품 1건 + 상세설명 HTML 편집기 + 버전 이력 | `cafe24` |
|
| `GET /cafe24/products/{product_no}/pane` | 오른쪽 편집기 조각 (JS 가 가져감) | `cafe24` |
|
||||||
|
| `GET /cafe24/products/{product_no}` | → `/cafe24/?selected=` 로 리다이렉트(옛 주소) | `cafe24` |
|
||||||
| `POST /cafe24/products/{product_no}/apply` | 편집한 HTML 을 카페24에 즉시 적용 | `cafe24` |
|
| `POST /cafe24/products/{product_no}/apply` | 편집한 HTML 을 카페24에 즉시 적용 | `cafe24` |
|
||||||
| `GET /cafe24/schedules` | 예약관리 (Phase 5 안내) | `cafe24` |
|
| `GET /cafe24/schedules` | 예약관리 (Phase 5 안내) | `cafe24` |
|
||||||
| `GET /cafe24/system` | 연결 상태 · API 로그 · 작업 로그 | `cafe24` |
|
| `GET /cafe24/system` | 연결 상태 · API 로그 · 작업 로그 | `cafe24` |
|
||||||
@@ -87,6 +88,34 @@ cafe24_oauth_tokens 저장
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
### 2-1. 상품관리 화면 구성 (2분할)
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─ 360px ─────────┬────────────── 남은 폭 전부 ──────────────┐
|
||||||
|
│ 검색 / 진열·판매 │ 선택한 상품 이름·상태 │
|
||||||
|
│ 필터(중복 선택) │ PC 상세설명 HTML 편집(칸이 남은 높이 차지) │
|
||||||
|
│ ── 목록 ── │ [메모] [복사] [카페24에 적용] │
|
||||||
|
│ 번호 상품명 진열 │ ▸ 모바일 HTML(읽기 전용, 분리 상품만) │
|
||||||
|
│ 판매 수정 │ ▸ 버전 이력 │
|
||||||
|
└─────────────────┴───────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
- **왼쪽은 전체 목록**(페이지 없음). `list_all_products` 로 페이지를 넘겨가며 전부
|
||||||
|
받는다(1회 100개, 상한 1000개). 필터를 한 페이지에만 적용하면 다음 페이지의
|
||||||
|
해당 상품이 빠지기 때문이다.
|
||||||
|
- **필터**는 `진열중`/`판매중` 체크박스이며 **중복 선택 시 AND** 다. 문서에 없는 API
|
||||||
|
파라미터에 기대지 않고 받아온 뒤 파이썬에서 걸러낸다.
|
||||||
|
- **정렬**은 제목행 클릭(오름↔내림 토글). 브라우저에서 처리하므로 전체를 받아둔
|
||||||
|
덕분에 목록 전체가 대상이 된다.
|
||||||
|
- **상품 클릭 시 오른쪽만 교체**한다(`/pane` 조각을 fetch → 삽입). 목록을 다시 받지
|
||||||
|
않으므로 카페24 호출이 1회로 끝난다. JS 실패 시 각 행의 링크로 정상 동작한다.
|
||||||
|
- 편집 중 다른 상품을 클릭하거나 페이지를 벗어나면 **저장 안 됨 경고**가 뜬다.
|
||||||
|
- `.erp-page` 의 `max-width` 를 이 화면에서만 풀어 편집 영역을 넓게 쓴다. 이때
|
||||||
|
`box-sizing: border-box` 를 함께 줘야 한다(안 주면 padding 이 폭에 더해져 문서에
|
||||||
|
가로 스크롤이 생긴다).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 3-1. 상세설명 API 사실 (실물 확인 결과 — 추측 금지)
|
## 3-1. 상세설명 API 사실 (실물 확인 결과 — 추측 금지)
|
||||||
|
|
||||||
운영 쇼핑몰(`miraskitchen`)에서 직접 확인한 내용이다. 문서에 없는 경로를
|
운영 쇼핑몰(`miraskitchen`)에서 직접 확인한 내용이다. 문서에 없는 경로를
|
||||||
|
|||||||
Reference in New Issue
Block a user