Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6b0be35832 | |||
| 45005fcc92 | |||
| df18bdfa7e | |||
| 281ade6ac9 | |||
| 835426b133 | |||
| d001fec21e | |||
| 253de3639c | |||
| a0507b8054 | |||
| 9f803880fa | |||
| 71fe964476 | |||
| 0f5c8c08ff | |||
| e032253327 | |||
| 3f108e2138 |
@@ -1,5 +1,13 @@
|
|||||||
name: CI
|
name: CI
|
||||||
|
|
||||||
|
# Required Secrets:
|
||||||
|
# - DJANGO_SECRET_KEY: Django Secret Key
|
||||||
|
# - token: Gitea API token for creating releases
|
||||||
|
# - ALIST_PUBLIC_URL: Public URL for AList download (e.g., http://alist.example.com/d/ci)
|
||||||
|
# - WEBDAV_URL: WebDAV upload URL (e.g., http://alist.example.com/dav/ci/)
|
||||||
|
# - WEBDAV_USER: WebDAV username
|
||||||
|
# - WEBDAV_PASSWORD: WebDAV password
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
@@ -18,15 +26,17 @@ jobs:
|
|||||||
docker-ci:
|
docker-ci:
|
||||||
if: github.event_name == 'workflow_dispatch' || (github.event_name == 'push' && contains(github.event.head_commit.message, '[ci]'))
|
if: github.event_name == 'workflow_dispatch' || (github.event_name == 'push' && contains(github.event.head_commit.message, '[ci]'))
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
container:
|
||||||
|
image: catthehacker/ubuntu:act-latest
|
||||||
timeout-minutes: 40
|
timeout-minutes: 40
|
||||||
env:
|
env:
|
||||||
DJANGO_SECRET_KEY: ${{ secrets.DJANGO_SECRET_KEY }}
|
DJANGO_SECRET_KEY: ${{ secrets.DJANGO_SECRET_KEY }}
|
||||||
DJANGO_DEBUG: "False"
|
DJANGO_DEBUG: "False"
|
||||||
DJANGO_ALLOWED_HOSTS: "127.0.0.1,localhost"
|
DJANGO_ALLOWED_HOSTS: "127.0.0.1,localhost"
|
||||||
IMAGE_NAME: achievement_inputing_ci
|
IMAGE_NAME: achievement_inputing_ci
|
||||||
ALIST_URL: https://alist.spdis.top
|
ARTIFACT_DIR: artifacts
|
||||||
ALIST_USER: ${{ secrets.ALIST_USER }}
|
# 请在 Secrets 中配置 ALIST_PUBLIC_URL,例如 http://139.224.69.213:8080/d/ci
|
||||||
ALIST_PASSWORD: ${{ secrets.ALIST_PASSWORD }}
|
DOWNLOAD_BASE: ${{ secrets.ALIST_PUBLIC_URL }}
|
||||||
GITEA_SERVER: ${{ github.server_url }}
|
GITEA_SERVER: ${{ github.server_url }}
|
||||||
GITEA_REPO: ${{ github.repository }}
|
GITEA_REPO: ${{ github.repository }}
|
||||||
RELEASE_TOKEN: ${{ secrets.token }}
|
RELEASE_TOKEN: ${{ secrets.token }}
|
||||||
@@ -74,35 +84,37 @@ jobs:
|
|||||||
ART="achievement_inputing_ci_${VERSION}.tar"
|
ART="achievement_inputing_ci_${VERSION}.tar"
|
||||||
docker save -o "$GITHUB_WORKSPACE/$ART" "$IMAGE_NAME:$VERSION"
|
docker save -o "$GITHUB_WORKSPACE/$ART" "$IMAGE_NAME:$VERSION"
|
||||||
echo "$ART" > "$GITHUB_WORKSPACE/.artifact_name"
|
echo "$ART" > "$GITHUB_WORKSPACE/.artifact_name"
|
||||||
- name: Upload to Alist
|
- name: Publish artifact locally
|
||||||
|
run: |
|
||||||
|
ART=$(cat "$GITHUB_WORKSPACE/.artifact_name")
|
||||||
|
mkdir -p "$GITHUB_WORKSPACE/$ARTIFACT_DIR"
|
||||||
|
mv "$GITHUB_WORKSPACE/$ART" "$GITHUB_WORKSPACE/$ARTIFACT_DIR/"
|
||||||
|
echo "artifact: $GITHUB_WORKSPACE/$ARTIFACT_DIR/$ART"
|
||||||
|
- name: Publish to WebDAV
|
||||||
|
env:
|
||||||
|
WEBDAV_URL: ${{ secrets.WEBDAV_URL }}
|
||||||
|
WEBDAV_USER: ${{ secrets.WEBDAV_USER }}
|
||||||
|
WEBDAV_PASSWORD: ${{ secrets.WEBDAV_PASSWORD }}
|
||||||
run: |
|
run: |
|
||||||
set -e
|
set -e
|
||||||
ART=$(cat "$GITHUB_WORKSPACE/.artifact_name")
|
ART=$(cat "$GITHUB_WORKSPACE/.artifact_name")
|
||||||
BASE="${ALIST_URL%/}"
|
FILE_PATH="$GITHUB_WORKSPACE/$ARTIFACT_DIR/$ART"
|
||||||
curl -sS -o "$GITHUB_WORKSPACE/login.json" -w "%{http_code}" -X POST "$BASE/api/auth/login" -H "Content-Type: application/json" -d "{\"username\":\"$ALIST_USER\",\"password\":\"$ALIST_PASSWORD\"}" > "$GITHUB_WORKSPACE/login.code"
|
|
||||||
if [ "$(cat "$GITHUB_WORKSPACE/login.code")" != "200" ]; then
|
# 检查必要的 secrets 是否存在
|
||||||
echo login_failed
|
if [ -z "$WEBDAV_URL" ]; then
|
||||||
cat "$GITHUB_WORKSPACE/login.json"
|
echo "Error: WEBDAV_URL secret is not set."
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
TOKEN=$(sed -n 's/.*"token":"\([^"]*\)".*/\1/p' "$GITHUB_WORKSPACE/login.json")
|
|
||||||
if [ -z "$TOKEN" ]; then
|
|
||||||
TOKEN=$(sed -n 's/.*"auth":"\([^"]*\)".*/\1/p' "$GITHUB_WORKSPACE/login.json")
|
|
||||||
fi
|
|
||||||
if [ -z "$TOKEN" ]; then
|
|
||||||
echo token_not_found
|
|
||||||
cat "$GITHUB_WORKSPACE/login.json"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
curl -sS -X POST "$BASE/api/fs/mkdir" -H "Authorization: $TOKEN" -H "Content-Type: application/json" -d "{\"path\":\"/ci\"}" >/dev/null 2>&1 || true
|
|
||||||
UPLOAD_STATUS=$(curl -sS -w "%{http_code}" -o /dev/null -X PUT "$BASE/api/fs/form" -H "Authorization: $TOKEN" -F "path=/ci" -F "file=@$GITHUB_WORKSPACE/$ART;type=application/x-tar")
|
|
||||||
if [ "$UPLOAD_STATUS" -ge 400 ]; then
|
|
||||||
UPLOAD_STATUS=$(curl -sS -w "%{http_code}" -o /dev/null -X PUT "$BASE/api/fs/form" -H "Authorization: Bearer $TOKEN" -F "path=/ci" -F "file=@$GITHUB_WORKSPACE/$ART;type=application/x-tar")
|
|
||||||
fi
|
|
||||||
if [ "$UPLOAD_STATUS" -ge 400 ]; then
|
|
||||||
echo upload_failed
|
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# 确保 URL 以 / 结尾
|
||||||
|
case "$WEBDAV_URL" in
|
||||||
|
*/) ;;
|
||||||
|
*) WEBDAV_URL="${WEBDAV_URL}/" ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
echo "Uploading $ART to $WEBDAV_URL..."
|
||||||
|
curl -f -u "$WEBDAV_USER:$WEBDAV_PASSWORD" -T "$FILE_PATH" "${WEBDAV_URL}${ART}"
|
||||||
|
echo "Upload success."
|
||||||
- name: Create release with download link
|
- name: Create release with download link
|
||||||
if: env.RELEASE_TOKEN != ''
|
if: env.RELEASE_TOKEN != ''
|
||||||
run: |
|
run: |
|
||||||
@@ -110,6 +122,8 @@ jobs:
|
|||||||
BRANCH=${GITHUB_REF#refs/heads/}
|
BRANCH=${GITHUB_REF#refs/heads/}
|
||||||
TAG="$VERSION"
|
TAG="$VERSION"
|
||||||
NAME="$VERSION"
|
NAME="$VERSION"
|
||||||
DL="${ALIST_URL%/}/ci/$ART"
|
BASE="${DOWNLOAD_BASE%/}"
|
||||||
|
DL="$BASE/$ART"
|
||||||
|
echo "download: $DL"
|
||||||
JSON=$(printf '{"tag_name":"%s","target_commitish":"%s","name":"%s","body":"%s"}' "$TAG" "$BRANCH" "$NAME" "$DL")
|
JSON=$(printf '{"tag_name":"%s","target_commitish":"%s","name":"%s","body":"%s"}' "$TAG" "$BRANCH" "$NAME" "$DL")
|
||||||
curl -sS -X POST "$GITEA_SERVER/api/v1/repos/$GITEA_REPO/releases" -H "Content-Type: application/json" -H "Authorization: token $RELEASE_TOKEN" -d "$JSON"
|
curl -sS -X POST "$GITEA_SERVER/api/v1/repos/$GITEA_REPO/releases" -H "Content-Type: application/json" -H "Authorization: token $RELEASE_TOKEN" -d "$JSON"
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ INSTALLED_APPS = [
|
|||||||
'accounts',
|
'accounts',
|
||||||
'main',
|
'main',
|
||||||
'elastic',
|
'elastic',
|
||||||
|
'minio_storage',
|
||||||
'django_elasticsearch_dsl',
|
'django_elasticsearch_dsl',
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -166,3 +167,4 @@ ELASTICSEARCH_INDEX_NAMES = {
|
|||||||
# AI Studio/OpenAI client settings
|
# AI Studio/OpenAI client settings
|
||||||
AISTUDIO_API_KEY = os.environ.get('AISTUDIO_API_KEY', '')
|
AISTUDIO_API_KEY = os.environ.get('AISTUDIO_API_KEY', '')
|
||||||
OPENAI_BASE_URL = os.environ.get('OPENAI_BASE_URL', 'https://aistudio.baidu.com/llm/lmapi/v3')
|
OPENAI_BASE_URL = os.environ.get('OPENAI_BASE_URL', 'https://aistudio.baidu.com/llm/lmapi/v3')
|
||||||
|
OPENAI_MODEL_NAME = os.environ.get('OPENAI_MODEL_NAME', 'ernie-4.5-turbo-vl-32k')
|
||||||
|
|||||||
@@ -328,7 +328,7 @@ function renderTable(data) {
|
|||||||
row.innerHTML = `
|
row.innerHTML = `
|
||||||
<td style="max-width:140px; word-break:break-all; font-size: 12px;">${item._id || item.id || ''}</td>
|
<td style="max-width:140px; word-break:break-all; font-size: 12px;">${item._id || item.id || ''}</td>
|
||||||
<td>
|
<td>
|
||||||
${item.image ? `<img src="/media/${item.image}" onerror="this.src=''; this.alt='图片加载失败'" class="clickable-image" data-image="/media/${item.image}" />` : '无图片'}
|
${item.image_url ? `<img src="${item.image_url}" onerror="this.src=''; this.alt='图片加载失败'" class="clickable-image" data-image="${item.image_url}" />` : '无图片'}
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<pre style="white-space:pre-wrap; word-wrap:break-word; max-height: 100px; overflow-y: auto; font-size: 12px; margin: 0;">${escapeHtml(displayData)}</pre>
|
<pre style="white-space:pre-wrap; word-wrap:break-word; max-height: 100px; overflow-y: auto; font-size: 12px; margin: 0;">${escapeHtml(displayData)}</pre>
|
||||||
|
|||||||
@@ -43,17 +43,21 @@
|
|||||||
.preview-box h3 {margin-top: 0;color: #334155; }
|
.preview-box h3 {margin-top: 0;color: #334155; }
|
||||||
.preview-box img { max-width: 100%;max-height: 300px;border: 1px solid #e2e8f0;border-radius: 8px;object-fit: contain;}
|
.preview-box img { max-width: 100%;max-height: 300px;border: 1px solid #e2e8f0;border-radius: 8px;object-fit: contain;}
|
||||||
.result-box {flex: 1;}
|
.result-box {flex: 1;}
|
||||||
.result-box h3 { margin-top: 0; color: #334155;}
|
.result-box h3 { margin-top: 0; color: #334155;}
|
||||||
.form-controls { display: flex;gap: 8px;margin-bottom: 12px;flex-wrap: wrap;}
|
.form-controls { display: flex;gap: 8px;margin-bottom: 12px;flex-wrap: wrap;}
|
||||||
#kvForm {border: 1px solid #e2e8f0; border-radius: 8px; padding: 12px; max-height: 300px; overflow: auto;margin-bottom: 12px;background: white;}
|
#kvForm {border: 1px solid #e2e8f0; border-radius: 8px; padding: 12px; max-height: 300px; overflow: auto;margin-bottom: 12px;background: white;}
|
||||||
.form-row {display: grid;grid-template-columns: 1fr 1fr auto;gap: 8px; margin-bottom: 6px; }
|
.form-row {display: grid;grid-template-columns: 1fr 1fr auto;gap: 8px; margin-bottom: 6px; }
|
||||||
.form-row input {padding: 8px;border: 1px solid #cbd5e1;border-radius: 4px;}
|
.form-row input {padding: 8px;border: 1px solid #cbd5e1;border-radius: 4px;}
|
||||||
#resultBox { width: 100%;min-height: 200px;font-family: ui-monospace, SFMono-Regular, Menlo, monospace;font-size: 14px; padding: 12px; border: 1px solid #e2e8f0;
|
#resultBox { width: 100%;min-height: 200px;font-family: ui-monospace, SFMono-Regular, Menlo, monospace;font-size: 14px; padding: 12px; border: 1px solid #e2e8f0;
|
||||||
border-radius: 8px; resize: vertical;box-sizing: border-box; }
|
border-radius: 8px; resize: vertical;box-sizing: border-box; }
|
||||||
.status-message { padding: 10px; margin: 10px 0; border-radius: 6px; display: none; }
|
.status-message { padding: 10px; margin: 10px 0; border-radius: 6px; display: none; }
|
||||||
.status-message.success { background-color: #d4edda; color: #155724; border: 1px solid #c3e6cb; }
|
.status-message.success { background-color: #d4edda; color: #155724; border: 1px solid #c3e6cb; }
|
||||||
.status-message.error { background-color: #f8d7da;color: #721c24; border: 1px solid #f5c6cb; }
|
.status-message.error { background-color: #f8d7da;color: #721c24; border: 1px solid #f5c6cb; }
|
||||||
.action-buttons { margin-top: 16px; display: flex; gap: 8px; flex-wrap: wrap; }
|
.action-buttons { margin-top: 16px; display: flex; gap: 8px; flex-wrap: wrap; }
|
||||||
|
.progress {position: relative; height: 12px; background: #e2e8f0; border-radius: 8px; overflow: hidden;}
|
||||||
|
.progress-bar {height: 100%; width: 0; background: linear-gradient(90deg, #4f46e5 0%, #60a5fa 100%); transition: width .2s ease;}
|
||||||
|
.progress-wrap {display:none; margin-top: 8px;}
|
||||||
|
.progress-text {margin-top: 6px; font-size: 12px; color: #334155;}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -90,6 +94,10 @@
|
|||||||
<button type="submit" class="btn btn-primary">上传并识别</button>
|
<button type="submit" class="btn btn-primary">上传并识别</button>
|
||||||
</form>
|
</form>
|
||||||
<div class="status-message" id="uploadMsg"></div>
|
<div class="status-message" id="uploadMsg"></div>
|
||||||
|
<div class="progress-wrap" id="progressWrap">
|
||||||
|
<div class="progress"><div class="progress-bar" id="progressBar"></div></div>
|
||||||
|
<div class="progress-text" id="progressText"></div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="preview-container">
|
<div class="preview-container">
|
||||||
@@ -136,9 +144,51 @@ const kvForm = document.getElementById('kvForm');
|
|||||||
const addFieldBtn = document.getElementById('addFieldBtn');
|
const addFieldBtn = document.getElementById('addFieldBtn');
|
||||||
const syncFromTextBtn = document.getElementById('syncFromTextBtn');
|
const syncFromTextBtn = document.getElementById('syncFromTextBtn');
|
||||||
const dropArea = document.getElementById('dropArea');
|
const dropArea = document.getElementById('dropArea');
|
||||||
|
const progressWrap = document.getElementById('progressWrap');
|
||||||
|
const progressBar = document.getElementById('progressBar');
|
||||||
|
const progressText = document.getElementById('progressText');
|
||||||
|
|
||||||
let currentImageRel = '';
|
let currentImageRel = '';
|
||||||
|
|
||||||
|
function setProgress(p, text){
|
||||||
|
const v = Math.max(0, Math.min(100, Math.round(p||0)));
|
||||||
|
progressBar.style.width = v + '%';
|
||||||
|
progressText.textContent = (text||'') + (text? ' ' : '') + v + '%';
|
||||||
|
}
|
||||||
|
function showProgress(){
|
||||||
|
progressWrap.style.display = 'block';
|
||||||
|
}
|
||||||
|
function hideProgress(){
|
||||||
|
progressWrap.style.display = 'none';
|
||||||
|
setProgress(0, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function convertToJpeg(file){
|
||||||
|
const url = URL.createObjectURL(file);
|
||||||
|
let img;
|
||||||
|
try{
|
||||||
|
const blob = await fetch(url).then(r=>r.blob());
|
||||||
|
img = await createImageBitmap(blob);
|
||||||
|
}catch(e){
|
||||||
|
img = await new Promise((resolve,reject)=>{const i=new Image();i.onload=()=>resolve(i);i.onerror=reject;i.src=url;});
|
||||||
|
}
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
const maxDim = 2000;
|
||||||
|
const w = img.width;
|
||||||
|
const h = img.height;
|
||||||
|
const scale = Math.min(1, maxDim/Math.max(w,h));
|
||||||
|
const nw = Math.round(w*scale);
|
||||||
|
const nh = Math.round(h*scale);
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
canvas.width = nw;
|
||||||
|
canvas.height = nh;
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
ctx.drawImage(img, 0, 0, nw, nh);
|
||||||
|
const blob = await new Promise(resolve=>canvas.toBlob(resolve,'image/jpeg',0.82));
|
||||||
|
const name = (file.name||'image').replace(/\.[^/.]+$/, '') + '.jpg';
|
||||||
|
return new File([blob], name, {type:'image/jpeg'});
|
||||||
|
}
|
||||||
|
|
||||||
// 拖拽上传功能
|
// 拖拽上传功能
|
||||||
['dragenter', 'dragover', 'dragleave', 'drop'].forEach(eventName => {
|
['dragenter', 'dragover', 'dragleave', 'drop'].forEach(eventName => {
|
||||||
dropArea.addEventListener(eventName, preventDefaults, false);
|
dropArea.addEventListener(eventName, preventDefaults, false);
|
||||||
@@ -289,20 +339,39 @@ uploadForm.addEventListener('submit', async (e) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
showProgress();
|
||||||
|
setProgress(5, '转换为JPG');
|
||||||
|
let jpegFile = file;
|
||||||
|
try {
|
||||||
|
jpegFile = await convertToJpeg(file);
|
||||||
|
setProgress(50, '转换为JPG');
|
||||||
|
preview.src = URL.createObjectURL(jpegFile);
|
||||||
|
} catch (_) {
|
||||||
|
jpegFile = file;
|
||||||
|
setProgress(50, '转换为JPG');
|
||||||
|
}
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append('file', file);
|
formData.append('file', jpegFile);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
let prog = 50;
|
||||||
|
setProgress(prog, '识别中');
|
||||||
|
const timer = setInterval(() => {
|
||||||
|
prog = Math.min(95, prog + 1);
|
||||||
|
setProgress(prog, '识别中');
|
||||||
|
}, 120);
|
||||||
const resp = await fetch('/elastic/upload/', {
|
const resp = await fetch('/elastic/upload/', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
credentials: 'same-origin',
|
credentials: 'same-origin',
|
||||||
headers: { 'X-CSRFToken': getCookie('csrftoken') || '' },
|
headers: { 'X-CSRFToken': getCookie('csrftoken') || '' },
|
||||||
body: formData,
|
body: formData,
|
||||||
});
|
});
|
||||||
|
clearInterval(timer);
|
||||||
const data = await resp.json();
|
const data = await resp.json();
|
||||||
if (!resp.ok || data.status !== 'success') {
|
if (!resp.ok || data.status !== 'success') {
|
||||||
throw new Error(data.message || '上传识别失败');
|
throw new Error(data.message || '上传识别失败');
|
||||||
}
|
}
|
||||||
|
setProgress(100, '识别完成');
|
||||||
uploadMsg.textContent = data.message || '识别成功';
|
uploadMsg.textContent = data.message || '识别成功';
|
||||||
uploadMsg.className = 'status-message success';
|
uploadMsg.className = 'status-message success';
|
||||||
uploadMsg.style.display = 'block';
|
uploadMsg.style.display = 'block';
|
||||||
@@ -310,10 +379,12 @@ uploadForm.addEventListener('submit', async (e) => {
|
|||||||
renderFormFromObject(data.data || {});
|
renderFormFromObject(data.data || {});
|
||||||
currentImageRel = data.image;
|
currentImageRel = data.image;
|
||||||
confirmBtn.disabled = false;
|
confirmBtn.disabled = false;
|
||||||
|
setTimeout(hideProgress, 800);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
uploadMsg.textContent = e.message || '发生错误';
|
uploadMsg.textContent = e.message || '发生错误';
|
||||||
uploadMsg.className = 'status-message error';
|
uploadMsg.className = 'status-message error';
|
||||||
uploadMsg.style.display = 'block';
|
uploadMsg.style.display = 'block';
|
||||||
|
progressText.textContent = '识别失败';
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,37 @@ from .es_connect import (
|
|||||||
from PIL import Image
|
from PIL import Image
|
||||||
|
|
||||||
|
|
||||||
|
def _image_ref_to_url(request, image_ref: str) -> str:
|
||||||
|
s = str(image_ref or '').strip()
|
||||||
|
if not s:
|
||||||
|
return ''
|
||||||
|
|
||||||
|
if not s.startswith('minio:'):
|
||||||
|
return ''
|
||||||
|
|
||||||
|
object_name = s[len('minio:'):].lstrip('/')
|
||||||
|
if not object_name:
|
||||||
|
return ''
|
||||||
|
|
||||||
|
try:
|
||||||
|
from minio_storage.minio_connect import presigned_get_url
|
||||||
|
return presigned_get_url(object_name, expires_seconds=8 * 60 * 60)
|
||||||
|
except Exception:
|
||||||
|
return ''
|
||||||
|
|
||||||
|
|
||||||
|
def _attach_image_urls(request, items):
|
||||||
|
out = []
|
||||||
|
for it in list(items or []):
|
||||||
|
try:
|
||||||
|
d = dict(it or {})
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
d['image_url'] = _image_ref_to_url(request, d.get('image', ''))
|
||||||
|
out.append(d)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
@require_http_methods(["GET", "POST"])
|
@require_http_methods(["GET", "POST"])
|
||||||
@csrf_exempt
|
@csrf_exempt
|
||||||
def init_index(request):
|
def init_index(request):
|
||||||
@@ -59,7 +90,7 @@ def search(request):
|
|||||||
return JsonResponse({"status": "error", "message": "搜索关键词不能为空"}, status=400)
|
return JsonResponse({"status": "error", "message": "搜索关键词不能为空"}, status=400)
|
||||||
|
|
||||||
results = search_data(query)
|
results = search_data(query)
|
||||||
return JsonResponse({"status": "success", "data": results})
|
return JsonResponse({"status": "success", "data": _attach_image_urls(request, results)})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return JsonResponse({"status": "error", "message": str(e)}, status=500)
|
return JsonResponse({"status": "error", "message": str(e)}, status=500)
|
||||||
|
|
||||||
@@ -73,7 +104,7 @@ def fuzzy_search(request):
|
|||||||
return JsonResponse({"status": "error", "message": "搜索关键词不能为空"}, status=400)
|
return JsonResponse({"status": "error", "message": "搜索关键词不能为空"}, status=400)
|
||||||
|
|
||||||
results = search_by_any_field(keyword)
|
results = search_by_any_field(keyword)
|
||||||
return JsonResponse({"status": "success", "data": results})
|
return JsonResponse({"status": "success", "data": _attach_image_urls(request, results)})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return JsonResponse({"status": "error", "message": str(e)}, status=500)
|
return JsonResponse({"status": "error", "message": str(e)}, status=500)
|
||||||
|
|
||||||
@@ -82,7 +113,7 @@ def get_all_data(request):
|
|||||||
"""获取所有数据"""
|
"""获取所有数据"""
|
||||||
try:
|
try:
|
||||||
results = search_all()
|
results = search_all()
|
||||||
return JsonResponse({"status": "success", "data": results})
|
return JsonResponse({"status": "success", "data": _attach_image_urls(request, results)})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return JsonResponse({"status": "error", "message": str(e)}, status=500)
|
return JsonResponse({"status": "error", "message": str(e)}, status=500)
|
||||||
|
|
||||||
@@ -176,7 +207,9 @@ def get_data(request, doc_id):
|
|||||||
try:
|
try:
|
||||||
result = get_by_id(doc_id)
|
result = get_by_id(doc_id)
|
||||||
if result:
|
if result:
|
||||||
return JsonResponse({"status": "success", "data": result})
|
wrapped = dict(result)
|
||||||
|
wrapped['image_url'] = _image_ref_to_url(request, wrapped.get('image', ''))
|
||||||
|
return JsonResponse({"status": "success", "data": wrapped})
|
||||||
else:
|
else:
|
||||||
return JsonResponse({"status": "error", "message": "数据不存在"}, status=404)
|
return JsonResponse({"status": "error", "message": "数据不存在"}, status=404)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -357,7 +390,7 @@ def ocr_and_extract_info(image_path: str):
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
model="ernie-4.5-turbo-vl-32k",
|
model=getattr(settings, "OPENAI_MODEL_NAME", "ernie-4.5-turbo-vl-32k"),
|
||||||
)
|
)
|
||||||
|
|
||||||
response_text = chat_completion.choices[0].message.content
|
response_text = chat_completion.choices[0].message.content
|
||||||
@@ -473,29 +506,45 @@ def confirm(request):
|
|||||||
return JsonResponse({"status": "error", "message": "数据不能为空"}, status=400)
|
return JsonResponse({"status": "error", "message": "数据不能为空"}, status=400)
|
||||||
|
|
||||||
ensure_type_in_list(edited.get("数据类型"))
|
ensure_type_in_list(edited.get("数据类型"))
|
||||||
final_image_rel = image_rel
|
image_ref_to_store = ""
|
||||||
try:
|
temp_files_to_delete = []
|
||||||
if image_rel:
|
if image_rel:
|
||||||
images_dir = os.path.join(settings.MEDIA_ROOT, "images")
|
images_dir = os.path.join(settings.MEDIA_ROOT, "images")
|
||||||
os.makedirs(images_dir, exist_ok=True)
|
os.makedirs(images_dir, exist_ok=True)
|
||||||
src_abs = os.path.join(settings.MEDIA_ROOT, image_rel)
|
src_abs = os.path.join(settings.MEDIA_ROOT, image_rel)
|
||||||
base = os.path.splitext(os.path.basename(image_rel))[0]
|
if not os.path.isfile(src_abs):
|
||||||
webp_name = base + ".webp"
|
return JsonResponse({"status": "error", "message": "图片文件不存在"}, status=400)
|
||||||
webp_abs = os.path.join(images_dir, webp_name)
|
|
||||||
|
webp_name = f"{uuid.uuid4().hex}.webp"
|
||||||
|
webp_abs = os.path.join(images_dir, webp_name)
|
||||||
|
try:
|
||||||
with Image.open(src_abs) as im:
|
with Image.open(src_abs) as im:
|
||||||
if im.mode in ("RGBA", "LA", "P"):
|
if im.mode in ("RGBA", "LA", "P"):
|
||||||
im = im.convert("RGBA")
|
im = im.convert("RGBA")
|
||||||
else:
|
else:
|
||||||
im = im.convert("RGB")
|
im = im.convert("RGB")
|
||||||
im.save(webp_abs, format="WEBP", quality=80)
|
im.save(webp_abs, format="WEBP", quality=80)
|
||||||
final_image_rel = f"images/{webp_name}"
|
except Exception:
|
||||||
except Exception:
|
try:
|
||||||
final_image_rel = image_rel
|
if os.path.isfile(webp_abs):
|
||||||
|
os.remove(webp_abs)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return JsonResponse({"status": "error", "message": "图片转换WEBP失败"}, status=500)
|
||||||
|
|
||||||
|
try:
|
||||||
|
object_name = f"images/{webp_name}"
|
||||||
|
from minio_storage.minio_connect import upload_file
|
||||||
|
upload_file(webp_abs, object_name, content_type="image/webp")
|
||||||
|
image_ref_to_store = f"minio:{object_name}"
|
||||||
|
temp_files_to_delete.extend([src_abs, webp_abs])
|
||||||
|
except Exception as e:
|
||||||
|
return JsonResponse({"status": "error", "message": f"上传到MinIO失败: {e}"}, status=500)
|
||||||
|
|
||||||
to_store = {
|
to_store = {
|
||||||
"writer_id": str(request.session.get("user_id")),
|
"writer_id": str(request.session.get("user_id")),
|
||||||
"data": json_to_string(edited),
|
"data": json_to_string(edited),
|
||||||
"image": final_image_rel,
|
"image": image_ref_to_store,
|
||||||
}
|
}
|
||||||
|
|
||||||
ok = insert_data(to_store)
|
ok = insert_data(to_store)
|
||||||
@@ -503,10 +552,9 @@ def confirm(request):
|
|||||||
return JsonResponse({"status": "error", "message": "写入ES失败"}, status=500)
|
return JsonResponse({"status": "error", "message": "写入ES失败"}, status=500)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if image_rel and final_image_rel != image_rel:
|
for p in temp_files_to_delete:
|
||||||
orig_abs = os.path.join(settings.MEDIA_ROOT, image_rel)
|
if p and os.path.isfile(p):
|
||||||
if os.path.isfile(orig_abs):
|
os.remove(p)
|
||||||
os.remove(orig_abs)
|
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|||||||
1
minio_storage/__init__.py
Normal file
1
minio_storage/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
22
minio_storage/apps.py
Normal file
22
minio_storage/apps.py
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
|
||||||
|
class MinioStorageConfig(AppConfig):
|
||||||
|
default_auto_field = 'django.db.models.BigAutoField'
|
||||||
|
name = 'minio_storage'
|
||||||
|
|
||||||
|
def ready(self):
|
||||||
|
if os.path.basename(sys.argv[0]) == 'manage.py':
|
||||||
|
if os.environ.get('RUN_MAIN') != 'true':
|
||||||
|
return
|
||||||
|
if 'runserver' not in sys.argv:
|
||||||
|
return
|
||||||
|
|
||||||
|
from .minio_connect import ensure_bucket_exists
|
||||||
|
try:
|
||||||
|
ensure_bucket_exists()
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ MinIO 初始化失败: {e}")
|
||||||
|
|
||||||
129
minio_storage/minio_connect.py
Normal file
129
minio_storage/minio_connect.py
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
import os
|
||||||
|
from datetime import timedelta
|
||||||
|
import mimetypes
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
|
from minio import Minio
|
||||||
|
from minio.error import S3Error
|
||||||
|
|
||||||
|
|
||||||
|
def _env_bool(name: str, default: bool = False) -> bool:
|
||||||
|
v = os.environ.get(name)
|
||||||
|
if v is None:
|
||||||
|
return default
|
||||||
|
return str(v).strip().lower() in {'1', 'true', 'yes', 'y', 'on'}
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_endpoint(minio_url: str):
|
||||||
|
if not minio_url:
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
u = str(minio_url).strip()
|
||||||
|
parsed = urlparse(u)
|
||||||
|
if parsed.scheme in {'http', 'https'}:
|
||||||
|
endpoint = parsed.netloc
|
||||||
|
secure = parsed.scheme == 'https'
|
||||||
|
else:
|
||||||
|
endpoint = u
|
||||||
|
secure = None
|
||||||
|
|
||||||
|
endpoint = endpoint.strip().rstrip('/')
|
||||||
|
return endpoint, secure
|
||||||
|
|
||||||
|
|
||||||
|
def _get_env(*names: str, default: str | None = None) -> str | None:
|
||||||
|
for n in names:
|
||||||
|
v = os.environ.get(n)
|
||||||
|
if v is not None and str(v).strip() != '':
|
||||||
|
return str(v).strip()
|
||||||
|
return default
|
||||||
|
|
||||||
|
|
||||||
|
def get_minio_client() -> Minio | None:
|
||||||
|
minio_url = _get_env('MINIO_URL', 'MINIO_ENDPOINT')
|
||||||
|
access_key = _get_env('MINIO_ACCESS_KEY')
|
||||||
|
secret_key = _get_env('MINIO_SECRET_KEY')
|
||||||
|
|
||||||
|
if not minio_url or not access_key or not secret_key:
|
||||||
|
return None
|
||||||
|
|
||||||
|
endpoint, secure_from_url = _normalize_endpoint(minio_url)
|
||||||
|
if not endpoint:
|
||||||
|
return None
|
||||||
|
|
||||||
|
secure = _env_bool('MINIO_SECURE', default=secure_from_url if secure_from_url is not None else False)
|
||||||
|
region = _get_env('MINIO_REGION', default=None)
|
||||||
|
|
||||||
|
return Minio(
|
||||||
|
endpoint=endpoint,
|
||||||
|
access_key=access_key,
|
||||||
|
secret_key=secret_key,
|
||||||
|
secure=secure,
|
||||||
|
region=region,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_bucket_name() -> str:
|
||||||
|
return _get_env('MINIO_BUCKET', default='achievement') or 'achievement'
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_bucket_exists() -> bool:
|
||||||
|
client = get_minio_client()
|
||||||
|
bucket = get_bucket_name()
|
||||||
|
if client is None:
|
||||||
|
print('ℹ️ MinIO 环境变量未配置,跳过桶检查')
|
||||||
|
return False
|
||||||
|
|
||||||
|
if not bucket:
|
||||||
|
print('ℹ️ MINIO_BUCKET 为空,跳过桶检查')
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
exists = client.bucket_exists(bucket)
|
||||||
|
except S3Error as e:
|
||||||
|
print(f'❌ MinIO 连接失败: {e}')
|
||||||
|
return False
|
||||||
|
|
||||||
|
if exists:
|
||||||
|
print(f'ℹ️ MinIO 桶已存在: {bucket}')
|
||||||
|
return True
|
||||||
|
|
||||||
|
try:
|
||||||
|
region = _get_env('MINIO_REGION', default=None)
|
||||||
|
if region:
|
||||||
|
client.make_bucket(bucket, location=region)
|
||||||
|
else:
|
||||||
|
client.make_bucket(bucket)
|
||||||
|
print(f'✅ MinIO 桶已创建: {bucket}')
|
||||||
|
return True
|
||||||
|
except S3Error as e:
|
||||||
|
print(f'❌ MinIO 创建桶失败: {e}')
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def upload_file(file_path: str, object_name: str, content_type: str | None = None) -> str:
|
||||||
|
client = get_minio_client()
|
||||||
|
if client is None:
|
||||||
|
raise RuntimeError('MinIO 未配置')
|
||||||
|
|
||||||
|
bucket = get_bucket_name()
|
||||||
|
ensure_bucket_exists()
|
||||||
|
|
||||||
|
ct = content_type
|
||||||
|
if not ct:
|
||||||
|
guessed, _ = mimetypes.guess_type(object_name)
|
||||||
|
ct = guessed or 'application/octet-stream'
|
||||||
|
|
||||||
|
client.fput_object(bucket, object_name, file_path, content_type=ct)
|
||||||
|
return object_name
|
||||||
|
|
||||||
|
|
||||||
|
def presigned_get_url(object_name: str, expires_seconds: int = 8 * 60 * 60) -> str:
|
||||||
|
client = get_minio_client()
|
||||||
|
if client is None:
|
||||||
|
raise RuntimeError('MinIO 未配置')
|
||||||
|
|
||||||
|
bucket = get_bucket_name()
|
||||||
|
ensure_bucket_exists()
|
||||||
|
exp = max(1, int(expires_seconds or 0))
|
||||||
|
return client.presigned_get_object(bucket, object_name, expires=timedelta(seconds=exp))
|
||||||
@@ -7,6 +7,7 @@ requests==2.32.3
|
|||||||
openai==1.52.2
|
openai==1.52.2
|
||||||
httpx==0.27.2
|
httpx==0.27.2
|
||||||
Pillow==10.4.0
|
Pillow==10.4.0
|
||||||
|
minio>=7.2.0,<8
|
||||||
gunicorn==21.2.0
|
gunicorn==21.2.0
|
||||||
whitenoise==6.6.0
|
whitenoise==6.6.0
|
||||||
django-browser-reload==1.21.0
|
django-browser-reload==1.21.0
|
||||||
|
|||||||
Reference in New Issue
Block a user